0.1.001 Split out web to separate repo

This commit is contained in:
Daniel Mason 2021-12-25 12:05:57 +13:00
parent 525d5c92b5
commit d268d939eb
65 changed files with 11 additions and 42509 deletions

View File

@ -1,9 +1,8 @@
stages:
- build
- bundle
variables:
VERSION: 0.0.33
VERSION: 0.1.001
build-go:
image: golang:1.16.7
@ -13,41 +12,9 @@ build-go:
script:
- go build -o goscrobble cmd/go-scrobble/*.go
artifacts:
expire_in: 1 day
expire_in: 1 week
paths:
- goscrobble
- migrations
- init
- .env.example
build-react:
image: node:15.12.0
stage: build
only:
- master
script:
- cd web
- npm install
- npm run build --env production
artifacts:
expire_in: 1 day
paths:
- web/build
bundle:
image: bash:latest
stage: bundle
only:
- master
variables:
GIT_STRATEGY: none
before_script:
- apk add --no-cache zip tar
script:
- zip -r goscrobble.${VERSION}.zip web/build goscrobble migrations init .env.example
- tar -czf goscrobble.${VERSION}.tar.gz web/build goscrobble migrations init .env.example
artifacts:
expire_in: 1 week
paths:
- goscrobble.${VERSION}.zip
- goscrobble.${VERSION}.tar.gz

View File

@ -37,6 +37,12 @@ We need to build NPM package, and then ship web/build with the binary.
go build -o goscrobble cmd/go-scrobble/*.go
./goscrobble
## Build API Docs
cd docs/api && docker run --rm --name slate -v $(pwd)/build:/srv/slate/build -v $(pwd)/source:/srv/slate/source slatedocs/slate build
## Test API Docs
cd docs/api && docker run --rm --name slate -p 4567:4567 -v $(pwd)/source:/srv/slate/source slatedocs/slate serve
## Support Development!
Feel free to support hosting and my coffee addiction https://liberapay.com/idanoo

View File

@ -99,6 +99,9 @@ func HandleRequests(port string) {
spaStatic := spaStaticHandler{staticPath: StaticDirectory}
r.PathPrefix("/img").Handler(spaStatic)
apiDocs := spaStaticHandler{staticPath: "docs/api/build"}
r.PathPrefix("/docs").Handler(apiDocs)
// SERVE FRONTEND - NO AUTH
spa := spaHandler{staticPath: "web/build", indexPath: "index.html"}
r.PathPrefix("/").Handler(spa)

View File

@ -1 +0,0 @@
REACT_APP_API_URL=http://127.0.0.1:42069

23
web/.gitignore vendored
View File

@ -1,23 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -1,70 +0,0 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

39643
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,56 +0,0 @@
{
"name": "goscrobble",
"version": "0.1.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.11.3",
"@reduxjs/toolkit": "^1.5.0",
"@testing-library/jest-dom": "^5.11.9",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.8.3",
"axios": "^0.21.1",
"bootstrap": "^4.6.0",
"formik": "^2.2.6",
"formik-material-ui": "^3.0.1",
"jwt-decode": "^3.1.2",
"react": "^17.0.2",
"react-bootstrap": "^1.5.2",
"react-confirm-alert": "^2.7.0",
"react-cookie": "^4.0.3",
"react-dom": "^17.0.2",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"react-spinners": "^0.10.6",
"react-timezone-select": "^0.10.7",
"react-toastify": "^7.0.3",
"reactjs-popup": "^2.0.4",
"reactstrap": "^8.9.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"redux-devtools-extension": "^2.13.9"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,65 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="GoScrobble.com Open source music scrobbler"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
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>
<div class="loader-container">
<div class="loader"></div>
</div>
</body>
</html>

View File

@ -1,52 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -1,25 +0,0 @@
{
"short_name": "GoScrobble",
"name": "GoScrobble - Go based scrobbler",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -1,368 +0,0 @@
import axios from 'axios';
import jwt from 'jwt-decode'
import { toast } from 'react-toastify';
function getHeaders() {
const user = JSON.parse(localStorage.getItem('user'));
if (user && user.jwt) {
var unixtime = Math.round((new Date()).getTime() / 1000);
if (user.exp < unixtime) {
// Trigger refresh
localStorage.removeItem('user');
window.location.reload();
// toast.warning("Session expired. Please log in again")
// window.location.reload();
return {};
}
return { Authorization: 'Bearer ' + user.jwt };
} else {
return {};
}
}
function getUserUuid() {
// TODO: move this to use Context values instead.
const user = JSON.parse(localStorage.getItem('user'));
if (user && user.uuid) {
return user.uuid
} else {
return '';
}
}
function handleErrorResp(error) {
if (error.response) {
if (error.response.status === 401) {
toast.error('Unauthorized')
} else if (error.response.status === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('An unknown error has occurred');
}
} else {
toast.error('Failed to connect to API');
}
return {};
}
export const PostLogin = (formValues) => {
return axios.post(process.env.REACT_APP_API_URL + "/api/v1/login", formValues)
.then((response) => {
if (response.data.token) {
let expandedUser = jwt(response.data.token)
let user = {
jwt: response.data.token,
uuid: expandedUser.sub,
exp: expandedUser.exp,
username: expandedUser.username,
admin: expandedUser.admin,
mod: expandedUser.mod,
refresh_token: expandedUser.refresh_token,
refresh_exp: expandedUser.refresh_exp,
}
toast.success('Successfully logged in.');
return user;
} else {
toast.error(response.data.error ? response.data.error: 'An Unknown Error has occurred');
return null
}
}).catch((error) => {
if (error.response === 401) {
toast.error('Unauthorized')
} else if (error.response === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('Failed to connect');
}
return Promise.resolve();
});
};
export const PostRefreshToken = (refreshToken) => {
return axios.post(process.env.REACT_APP_API_URL + "/api/v1/refresh", {token: refreshToken})
.then((response) => {
if (response.data.token) {
let expandedUser = jwt(response.data.token)
let user = {
jwt: response.data.token,
uuid: expandedUser.sub,
exp: expandedUser.exp,
username: expandedUser.username,
admin: expandedUser.admin,
mod: expandedUser.mod,
refresh_token: expandedUser.refresh_token,
refresh_exp: expandedUser.refresh_exp,
}
return user;
} else {
toast.error(response.data.error ? response.data.error: 'An Unknown Error has occurred');
return null
}
}).catch((error) => {
if (error.response === 401) {
toast.error('Unauthorized')
} else if (error.response === 429) {
toast.error('Rate limited. Please try again shortly')
} else {
toast.error('Failed to connect');
}
return Promise.resolve();
});
};
export const PostRegister = (formValues) => {
return axios.post(process.env.REACT_APP_API_URL + "/api/v1/register", formValues)
.then((response) => {
if (response.data.message) {
toast.success(response.data.message);
return Promise.resolve();
} else {
toast.error(response.data.error ? response.data.error: 'An Unknown Error has occurred');
return Promise.reject();
}
}).catch((error) => {
handleErrorResp(error)
return Promise.resolve();
});
};
export const PostResetPassword = (formValues) => {
return axios.post(process.env.REACT_APP_API_URL + "/api/v1/resetpassword", formValues)
.then((response) => {
if (response.data.message) {
toast.success(response.data.message);
return Promise.resolve();
} else {
toast.error(response.data.error ? response.data.error: 'An Unknown Error has occurred');
return Promise.reject();
}
}).catch((error) => {
handleErrorResp(error)
return Promise.resolve();
});
};
export const sendPasswordReset = (values) => {
return axios.post(process.env.REACT_APP_API_URL + "/api/v1/sendreset", values).then(
(data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
};
export const getStats = () => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/stats").then(
(data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
};
export const getRecentScrobbles = (id) => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/user/" + id + "/scrobbles", { headers: getHeaders() })
.then((data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
};
export const getConfigs = () => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/config", { headers: getHeaders() })
.then((data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
};
export const postConfigs = (values, toggle) => {
if (toggle) {
values.REGISTRATION_ENABLED = "1"
} else {
values.REGISTRATION_ENABLED = "0"
}
return axios.post(process.env.REACT_APP_API_URL + "/api/v1/config", values, { headers: getHeaders() })
.then((data) => {
if (data.data && data.data.message) {
toast.success(data.data.message);
} else if (data.data && data.data.error) {
toast.error(data.data.error);
}
}).catch((error) => {
return handleErrorResp(error)
});
};
export const getProfile = (userName) => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/profile/" + userName, { headers: getHeaders() })
.then((data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
};
export const getUser = () => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/user", { headers: getHeaders() })
.then((data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
};
export const patchUser = (values) => {
return axios.patch(process.env.REACT_APP_API_URL + "/api/v1/user", values, { headers: getHeaders() })
.then((data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
};
export const validateResetPassword = (tokenStr) => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/user/", { headers: getHeaders() })
.then((data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
};
export const getSpotifyClientId = () => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/user/spotify", { headers: getHeaders() })
.then((data) => {
return data.data
}).catch((error) => {
return handleErrorResp(error)
});
}
export const spotifyConnectionRequest = () => {
return getSpotifyClientId().then((resp) => {
var scopes = 'user-read-recently-played user-read-currently-playing';
// Local, lets forward it to API
let redirectUri = window.location.origin.toString()+ "/api/v1/link/spotify";
// Stupid dev
if (window.location.origin.toString() === "http://localhost:3000") {
redirectUri = "http://localhost:42069/api/v1/link/spotify"
}
window.location = 'https://accounts.spotify.com/authorize' +
'?response_type=code' +
'&client_id=' + resp.token +
'&scope=' + encodeURIComponent(scopes) +
'&redirect_uri=' + encodeURIComponent(redirectUri) +
'&state=' + getUserUuid();
})
};
export const spotifyDisonnectionRequest = () => {
return axios.delete(process.env.REACT_APP_API_URL + "/api/v1/user/spotify", { headers: getHeaders() })
.then((data) => {
toast.success(data.data.message);
return true
}).catch((error) => {
return handleErrorResp(error)
});
}
export const navidromeConnectionRequest = (values) => {
return axios.post(process.env.REACT_APP_API_URL + "/api/v1/user/navidrome", values, { headers: getHeaders() })
.then((data) => {
toast.success(data.data.message);
return true
}).catch((error) => {
return handleErrorResp(error)
});
};
export const navidromeDisonnectionRequest = () => {
return axios.delete(process.env.REACT_APP_API_URL + "/api/v1/user/navidrome", { headers: getHeaders() })
.then((data) => {
toast.success(data.data.message);
return true
}).catch((error) => {
return handleErrorResp(error)
});
}
export const getServerInfo = () => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/serverinfo")
.then((data) => {
return data.data
}).catch((error) => {
return handleErrorResp(error)
});
}
export const getArtist = (uuid) => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/artists/" + uuid).then(
(data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
};
export const getAlbum = (uuid) => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/albums/" + uuid).then(
(data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
};
export const getTrack = (uuid) => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/tracks/" + uuid).then(
(data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
};
export const getTopTracks = (uuid) => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/tracks/top/" + uuid).then(
(data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
}
export const getTopArtists = (uuid) => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/artists/top/" + uuid).then(
(data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
}
export const getTopUsersForTrack = (uuid) => {
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/tracks/" + uuid + "/top").then(
(data) => {
return data.data;
}).catch((error) => {
return handleErrorResp(error)
});
}

View File

@ -1,68 +0,0 @@
html, body {
background-color: #282c34;
/** WHY DOES THIS DEFAULT TO 1.5 */
line-height: 1.3!important;
}
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 0.5s linear;
}
}
.toastNotifs {
margin-top: 100px;
z-index: 99999!important;
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.pageWrapper {
background-color: #282c34;
padding: 90px 15px 0 15px;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
font-size: calc(10px + 2vmin);
color: white;
}
.pageBody {
padding: 20px 5px 5px 5px;
font-size: 16pt;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
0% {
transform: scale(1,1);
}
50% {
transform: scale(1.05,1.05);
}
100% {
transform: scale(1,1);
}
}

View File

@ -1,57 +0,0 @@
import { Route, Switch, withRouter } from 'react-router-dom';
import Home from './Pages/Home';
import About from './Pages/About';
import Profile from './Pages/Profile';
import Artist from './Pages/Artist';
import Album from './Pages/Album';
import Track from './Pages/Track';
import TrackEdit from './Pages/TrackEdit';
import User from './Pages/User';
import Admin from './Pages/Admin';
import Login from './Pages/Login';
import Register from './Pages/Register';
import Reset from './Pages/Reset';
import Navigation from './Components/Navigation';
import 'bootstrap/dist/css/bootstrap.min.css';
import './App.css';
const App = () => {
let boolTrue = true;
// Remove loading spinner on load
const el = document.querySelector(".loader-container");
if (el) {
el.remove();
}
return (
<div>
<Navigation />
<Switch>
<Route exact={boolTrue} path={["/", "/home"]} component={Home} />
<Route path="/about" component={About} />
<Route path="/user" component={User} />
<Route path="/u/:uuid" component={Profile} />
<Route path="/artist/:uuid" component={Artist} />
<Route path="/album/:uuid" component={Album} />
<Route path="/track/:uuid/edit" component={TrackEdit} />
<Route path="/track/:uuid" component={Track} />
<Route path="/admin" component={Admin} />
<Route path="/login" component={Login} />
<Route path="/register" component={Register} />
<Route path="/reset/:token" component={Reset} />
<Route path="/reset" component={Reset} />
</Switch>
</div>
);
}
export default withRouter(App);

View File

@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -1,15 +0,0 @@
.homeBanner {
margin-top: 30px;
width: 100%;
max-width: 1100px;
}
.homeBannerItem {
float: left;
text-align: center;
width: 25%;
}
.homeBannerItemCount {
font-size: 1.9rem;
}

View File

@ -1,55 +0,0 @@
import React, { useEffect, useState } from 'react';
import '../App.css';
import './HomeBanner.css';
import { getStats } from '../Api/index';
import ClipLoader from 'react-spinners/ClipLoader'
const HomeBanner = () => {
let [bannerData, setBannerData] = useState({});
let [isLoading, setIsLoading] = useState(true);
useEffect(() => {
getStats()
.then(data => {
if (data.users) {
setBannerData(data);
setIsLoading(false);
}
})
}, [])
return (
<div className="homeBanner">
<div className="homeBannerItem">
{isLoading
? <ClipLoader color="#6AD7E5" size={34} />
: <span className="homeBannerItemCount">{bannerData.scrobbles}</span>
}
<br/>Scrobbles
</div>
<div className="homeBannerItem">
{isLoading
? <ClipLoader color="#6AD7E5" size={34} />
: <span className="homeBannerItemCount">{bannerData.users}</span>
}
<br/>Users
</div>
<div className="homeBannerItem">
{isLoading
? <ClipLoader color="#6AD7E5" size={34} />
: <span className="homeBannerItemCount">{bannerData.tracks}</span>
}
<br/>Tracks
</div>
<div className="homeBannerItem">
{isLoading
? <ClipLoader color="#6AD7E5" size={34} />
: <span className="homeBannerItemCount">{bannerData.artists}</span>
}
<br/>Artists
</div>
</div>
);
}
export default HomeBanner;

View File

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

View File

@ -1,181 +0,0 @@
import { React, useState, useContext } from 'react';
import { Navbar, NavbarBrand, Collapse, Nav, NavbarToggler, NavItem } from 'reactstrap';
import { Link, useLocation } from 'react-router-dom';
import logo from '../logo.png';
import './Navigation.css';
import AuthContext from '../Contexts/AuthContext';
const menuItems = [
'Home',
// 'About',
];
const loggedInMenuItems = [
'Home',
'My Profile',
// 'Docs',
]
const isMobile = () => {
return (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent))
};
const Navigation = () => {
const location = useLocation();
// Lovely hack to highlight the current page (:
let active = "home"
if (location && location.pathname && location.pathname.length > 1) {
active = location.pathname.replace(/\//, "");
}
let activeStyle = { color: '#FFFFFF' };
let { user, Logout } = useContext(AuthContext);
let [collapsed, setCollapsed] = useState(true);
const toggleCollapsed = () => {
setCollapsed(!collapsed)
}
const renderMobileNav = () => {
return <Navbar color="dark" dark fixed="top">
<NavbarBrand className="mr-auto"><img src={logo} className="nav-logo" alt="logo" /> GoScrobble</NavbarBrand>
<NavbarToggler onClick={toggleCollapsed} className="mr-2" />
<Collapse isOpen={!collapsed} navbar>
{user ?
<Nav className="navLinkLoginMobile" navbar>
{loggedInMenuItems.map(menuItem =>
<NavItem key={menuItem}>
<Link
key={menuItem}
className="navLinkMobile"
style={active === menuItem.toLowerCase() ? activeStyle : {}}
to={menuItem === "My Profile" ? "/u/" + user.username : "/" + menuItem.toLowerCase()} onClick={toggleCollapsed}
>{menuItem}</Link>
</NavItem>
)}
<Link
to="/user"
style={active === "user" ? activeStyle : {}}
className="navLinkMobile"
onClick={toggleCollapsed}
>Settings</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 key={menuItem}>
<Link
key={menuItem}
className="navLinkMobile"
style={active === "home" && menuItem.toLowerCase() === "home" ? activeStyle : (active === menuItem.toLowerCase() ? activeStyle : {})}
to={menuItem.toLowerCase() === "home" ? "/" : "/" + menuItem.toLowerCase()}
onClick={toggleCollapsed}
>{menuItem}
</Link>
</NavItem>
)}
<NavItem>
<Link
to="/login"
style={active === "login" ? activeStyle : {}}
className="navLinkMobile"
onClick={toggleCollapsed}
>Login</Link>
</NavItem>
<NavItem>
<Link
to="/register"
className="navLinkMobile"
style={active === "register" ? activeStyle : {}}
onClick={toggleCollapsed}
>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>
{user ?
<div>
{loggedInMenuItems.map(menuItem =>
<Link
key={menuItem}
className="navLink"
style={active === menuItem.toLowerCase() ? activeStyle : {}}
to={menuItem === "My Profile" ? "/u/" + user.username : "/" + menuItem.toLowerCase()}
>
{menuItem}
</Link>
)}
</div>
: <div>
{menuItems.map(menuItem =>
<Link
key={menuItem}
className="navLink"
style={active === "home" && menuItem.toLowerCase() === "home" ? activeStyle : (active === menuItem.toLowerCase() ? activeStyle : {})}
to={menuItem.toLowerCase() === "home" ? "/" : "/" + menuItem.toLowerCase()}
>
{menuItem}
</Link>
)}
</div>
}
{user ?
<div className="navLinkLogin">
<Link
to="/user"
style={active === "user" ? activeStyle : {}}
className="navLink"
>Settings</Link>
{user.admin &&
<Link
to="/admin"
style={active === "admin" ? activeStyle : {}}
className="navLink"
>Admin</Link>}
<Link to="/admin" className="navLink" onClick={Logout}>Logout</Link>
</div>
:
<div className="navLinkLogin">
<Link
to="/login"
style={active === "login" ? activeStyle : {}}
className="navLink"
>Login</Link>
<Link
to="/register"
className="navLink"
style={active === "register" ? activeStyle : {}}
>Register</Link>
</div>
}
</Navbar>
}
return (
<div>
{
isMobile()
? renderMobileNav()
: renderDesktopNav()
}
</div>
);
}
export default Navigation;

View File

@ -1,36 +0,0 @@
import React from "react";
import { Link } from 'react-router-dom';
const ScrobbleTable = (props) => {
return (
<div style={{
border: `1px solid #FFFFFF`,
width: `100%`,
display: `flex`,
flexWrap: `wrap`,
minWidth: `300px`,
maxWidth: `900px`,
}}>
{
props.data &&
props.data.map(function (element) {
let localTime = new Date(element.time);
return <div style={{borderBottom: `1px solid #CCC`, width: `100%`, padding: `2px`}} key={"box" + element.time}>
{localTime.toLocaleString()}<br/>
<Link
key={"artist" + element.time}
to={"/artist/"+element.artist.uuid}
>{element.artist.name}</Link> -
<Link
key={"track" + element.time}
to={"/track/"+element.track.uuid}
> {element.track.name}</Link>
</div>;
})
}
</div>
);
}
export default ScrobbleTable;

View File

@ -1,13 +0,0 @@
.biggestWrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
.biggestBox {
margin: 0;
padding: 0;
width: 300px;
height: 300px;
}

View File

@ -1,131 +0,0 @@
import React from "react";
import './TopTable.css'
import TopTableBox from './TopTableBox';
const TopTable = (props) => {
if (!props.items || Object.keys(props.items).length < 1) {
return (
<span>Not enough data to show top {props.type}s.<br/></span>
)
}
let tracks = props.items;
return (
<div style={{textAlign: `center`}}>
<span>Top {props.type}s</span>
<div className="biggestWrapper">
<div className="biggestBox">
<TopTableBox
size={300}
number="1"
title={tracks[1].name}
link={"/" + props.type + "/" + tracks[1].uuid}
uuid={tracks[1].img}
/>
</div>
{ Object.keys(props.items).length > 5 &&
<div className="biggestBox">
<TopTableBox
size={150}
number="2"
title={tracks[2].name}
link={"/" + props.type + "/" + tracks[2].uuid}
uuid={tracks[2].img}
/>
<TopTableBox
size={150}
number="3"
title={tracks[3].name}
link={"/" + props.type + "/" + tracks[3].uuid}
uuid={tracks[3].img}
/>
<TopTableBox
size={150}
number="4"
title={tracks[4].name}
link={"/" + props.type + "/" + tracks[4].uuid}
uuid={tracks[4].img}
/>
<TopTableBox
size={150}
number="5"
title={tracks[5].name}
link={"/" + props.type + "/" + tracks[5].uuid}
uuid={tracks[5].img}
/>
</div>
}
{ Object.keys(props.items).length >= 14 &&
<div className="biggestBox">
<TopTableBox
size={100}
number="6"
title={tracks[6].name}
link={"/" + props.type + "/" + tracks[6].uuid}
uuid={tracks[6].img}
/>
<TopTableBox
size={100}
number="7"
title={tracks[7].name}
link={"/" + props.type + "/" + tracks[7].uuid}
uuid={tracks[7].img}
/>
<TopTableBox
size={100}
number="8"
title={tracks[8].name}
link={"/" + props.type + "/" + tracks[8].uuid}
uuid={tracks[8].img}
/>
<TopTableBox
size={100}
number="9"
title={tracks[9].name}
link={"/" + props.type + "/" + tracks[9].uuid}
uuid={tracks[9].img}
/>
<TopTableBox
size={100}
number="10"
title={tracks[10].name}
link={"/" + props.type + "/" + tracks[10].uuid}
uuid={tracks[10].img}
/>
<TopTableBox
size={100}
number="11"
title={tracks[11].name}
link={"/" + props.type + "/" + tracks[11].uuid}
uuid={tracks[11].img}
/>
<TopTableBox
size={100}
number="12"
title={tracks[12].name}
link={"/" + props.type + "/" + tracks[12].uuid}
uuid={tracks[12].img}
/>
<TopTableBox
size={100}
number="13"
title={tracks[13].name}
link={"/" + props.type + "/" + tracks[13].uuid}
uuid={tracks[13].img}
/>
<TopTableBox
size={100}
number="14"
title={tracks[14].name}
link={"/" + props.type + "/" + tracks[14].uuid}
uuid={tracks[14].img}
/>
</div>
}
</div>
</div>
);
}
export default TopTable;

View File

@ -1,22 +0,0 @@
.topTableBox:hover {
opacity: 0.7;
cursor: pointer;
}
img {
background-size: cover;
background-position: top center;
}
.topOverlay {
position: absolute;
padding: 2px 5px 5px 5px;
background-color: rgba(0, 0, 0, 0.6);
line-height: 0.6em;
}
.topText {
margin: -2px 0 0 0;
font-size: 11pt;
color: #FFF;
}

View File

@ -1,29 +0,0 @@
import React from "react";
import { Link } from 'react-router-dom';
import './TopTableBox.css'
const TopTableBox = (props) => {
return (
<Link to={props.link} float="left" >
<div
className="topTableBox"
style={{
backgroundImage: `url(${process.env.REACT_APP_API_URL + "/img/" + props.uuid + "_full.jpg"})`,
backgroundSize: `cover`,
backgroundPosition: `top center`,
width: `${props.size}px`,
height: `${props.size}px`,
float: `left`,
}} >
<div className="topOverlay" style={{ maxWidth: `${props.size-'5'}px` }}>
<span className="topText" style={{
fontSize: `${props.size === 300 ? '11pt' : (props.size === 150 ? '8pt': '8pt' )}`
}}>#{props.number} {props.title}</span>
</div>
</div>
</Link>
);
}
export default TopTableBox;

View File

@ -1,56 +0,0 @@
import { Link } from 'react-router-dom';
import './TopUserTable.css'
import React, { useState, useEffect } from 'react';
import ScaleLoader from 'react-spinners/ScaleLoader';
import { getTopUsersForTrack } from '../Api/index'
const TopUserTable = (props) => {
const [loading, setLoading] = useState(true);
const [data, setData] = useState({});
useEffect(() => {
if (!props.uuid) {
return false;
}
getTopUsersForTrack(props.uuid)
.then(data => {
setData(data);
setLoading(false);
})
}, [props.uuid])
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
return (
<div style={{
width: `100%`,
display: `flex`,
flexWrap: `wrap`,
marginLeft: `20px`,
textAlign: `left`,
}}>
{
data.items &&
data.items.map(function (element) {
return <div style={{width: `100%`, padding: `2px`}} key={"box" + props.uuid}>
<Link
key={"user" + element.user_uuid}
to={"/u/"+element.user_name}
>{element.user_name}</Link> ({element.count})
</div>;
})
}
</div>
);
}
export default TopUserTable;

View File

@ -1,5 +0,0 @@
import React from 'react';
const AuthContext = React.createContext();
export default AuthContext;

View File

@ -1,81 +0,0 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import AuthContext from './AuthContext';
import { PostLogin, PostRegister, PostResetPassword, PostRefreshToken } from '../Api/index';
const AuthContextProvider = ({ children }) => {
const [user, setUser] = useState();
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true)
let curTime = Math.round((new Date()).getTime() / 1000);
let user = JSON.parse(localStorage.getItem('user'));
// Confirm JWT is set.
if (user && user.jwt) {
// Check refresh expiry is valid.
if (user.refresh_exp && (user.refresh_exp > curTime)) {
// Check if JWT is still valid
if (user.exp < curTime) {
// Refresh if not
user = RefreshToken(user.refresh_token)
localStorage.setItem('user', JSON.stringify(user));
}
setUser(user);
}
}
setLoading(false)
}, []);
const Login = (formValues) => {
setLoading(true);
PostLogin(formValues).then(user => {
if (user) {
setUser(user);
localStorage.setItem('user', JSON.stringify(user));
}
setLoading(false);
})
}
const Register = (formValues) => {
setLoading(true);
return PostRegister(formValues).then(response => {
setLoading(false);
});
};
const ResetPassword = (formValues) => {
return PostResetPassword(formValues);
}
const RefreshToken = (refreshToken) => {
return PostRefreshToken(refreshToken);
}
const Logout = () => {
localStorage.removeItem("user");
setUser(null)
toast.success('Successfully logged out.');
};
return (
<AuthContext.Provider
value={{
Logout,
Login,
Register,
ResetPassword,
RefreshToken,
loading,
user,
}}
>
{children}
</AuthContext.Provider>
);
};
export default AuthContextProvider;

View File

@ -1,3 +0,0 @@
import { createBrowserHistory } from "history";
export const history = createBrowserHistory();

View File

@ -1 +0,0 @@

View File

@ -1,25 +0,0 @@
import '../App.css';
import './About.css';
const About = () => {
return (
<div className="pageWrapper">
<h1>
About GoScrobble.com
</h1>
<p className="aboutBody">
Go-Scrobble is an open source music scrobbling service written in Go and React.<br/>
Used to track your listening history and build a profile to discover new music.
</p>
<a
className="pageBody"
href="https://gitlab.com/idanoo/go-scrobble"
target="_blank"
rel="noopener noreferrer"
>gitlab.com/idanoo/go-scrobble
</a>
</div>
);
}
export default About;

View File

@ -1,9 +0,0 @@
.adminFields {
width: 100%;
}
.admin {
height: 50px;
width: 100%;
margin-top:-5px;
}

View File

@ -1,112 +0,0 @@
import React, { useContext, useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import '../App.css';
import './Admin.css';
import { Button } from 'reactstrap';
import { Formik, Form, Field } from 'formik';
import ScaleLoader from 'react-spinners/ScaleLoader';
import AuthContext from '../Contexts/AuthContext';
import { Switch } from 'formik-material-ui';
import { getConfigs, postConfigs } from '../Api/index'
const Admin = () => {
const history = useHistory();
const { user } = useContext(AuthContext);
const [loading, setLoading] = useState(true);
const [configs, setConfigs] = useState({})
const [toggle, setToggle] = useState(false);
useEffect(() => {
getConfigs()
.then(data => {
if (data.configs) {
setConfigs(data.configs);
setToggle(data.configs.REGISTRATION_ENABLED === "1")
setLoading(false);
}
})
}, [])
const handleToggle = () => {
setToggle(!toggle);
};
if (!user) {
history.push("/login")
}
if (user && !user.admin) {
history.push("/Dashboard")
}
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
return (
<div className="pageWrapper">
<h1>
Admin Panel
</h1>
<div className="pageBody">
<Formik
initialValues={configs}
onSubmit={(values) => postConfigs(values, toggle)}
>
<Form><br/>
<label>
<Field
type="checkbox"
name="REGISTRATION_ENABLED"
onChange={handleToggle}
component={Switch}
checked={toggle}
value={toggle}
/>
Registration Enabled
</label><br/><br/>
<label>
LastFM Api Key<br/>
<Field
name="LASTFM_API_KEY"
type="text"
className="loginFields"
/>
</label>
<br/>
<label>
Spotify App ID<br/>
<Field
name="SPOTIFY_APP_ID"
type="text"
className="loginFields"
/>
</label>
<label>
Spotify App Secret<br/>
<Field
name="SPOTIFY_APP_SECRET"
type="text"
className="loginFields"
/>
</label>
<br/><br/>
<Button
color="primary"
type="submit"
className="loginButton"
disabled={loading}
>Update</Button>
</Form>
</Formik>
</div>
</div>
);
}
export default Admin;

View File

@ -1,60 +0,0 @@
import React, { useState, useEffect } from 'react';
import '../App.css';
import './Album.css';
import ScaleLoader from 'react-spinners/ScaleLoader';
import { getAlbum } from '../Api/index'
const Album = (route) => {
const [loading, setLoading] = useState(true);
const [album, setAlbum] = useState({});
let albumUUID = false;
if (route && route.match && route.match.params && route.match.params.uuid) {
albumUUID = route.match.params.uuid;
} else {
albumUUID = false;
}
useEffect(() => {
if (!albumUUID) {
return false;
}
getAlbum(albumUUID)
.then(data => {
setAlbum(data);
setLoading(false);
})
}, [albumUUID])
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
if (!albumUUID || !album) {
return (
<div className="pageWrapper">
Unable to fetch user
</div>
)
}
return (
<div className="pageWrapper">
<h1>
{album.name}
</h1>
<div className="pageBody">
<img src={process.env.REACT_APP_API_URL + "/img/" + album.uuid + "_full.jpg"} alt={album.name} style={{maxWidth: `300px`, maxHeight: `300px`}}/><br/><br/>
{album.mbid && <a rel="noreferrer" target="_blank" href={"https://musicbrainz.org/album/" + album.mbid}>Open on MusicBrainz<br/></a>}
{album.spotify_id && <a rel="noreferrer" target="_blank" href={"https://open.spotify.com/album/" + album.spotify_id}>Open on Spotify<br/></a>}
</div>
</div>
);
}
export default Album;

View File

@ -1,60 +0,0 @@
import React, { useState, useEffect } from 'react';
import '../App.css';
import './Artist.css';
import ScaleLoader from 'react-spinners/ScaleLoader';
import { getArtist } from '../Api/index'
const Artist = (route) => {
const [loading, setLoading] = useState(true);
const [artist, setArtist] = useState({});
let artistUUID = false;
if (route && route.match && route.match.params && route.match.params.uuid) {
artistUUID = route.match.params.uuid;
} else {
artistUUID = false;
}
useEffect(() => {
if (!artistUUID) {
return false;
}
getArtist(artistUUID)
.then(data => {
setArtist(data);
setLoading(false);
})
}, [artistUUID])
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
if (!artistUUID || !artist) {
return (
<div className="pageWrapper">
Unable to fetch user
</div>
)
}
return (
<div className="pageWrapper">
<h1>
{artist.name}
</h1>
<div className="pageBody" style={{textAlign: `center`}}>
<img src={process.env.REACT_APP_API_URL + "/img/" + artist.uuid + "_full.jpg"} alt={artist.name} style={{maxWidth: `300px`, maxHeight: `300px`}}/><br/><br/>
{artist.mbid && <a rel="noreferrer" target="_blank" href={"https://musicbrainz.org/artist/" + artist.mbid}>Open on MusicBrainz<br/></a>}
{artist.spotify_id && <a rel="noreferrer" target="_blank" href={"https://open.spotify.com/artist/" + artist.spotify_id}>Open on Spotify<br/></a>}
</div>
</div>
);
}
export default Artist;

View File

@ -1,11 +0,0 @@
.homeText {
margin: 0;
font-size: 2rem;
}
.subHomeText {
margin-top: -5px;
font-style: italic;
color: #CCC;
font-size: 1.4rem;
}

View File

@ -1,18 +0,0 @@
import logo from '../logo.png';
import '../App.css';
import './Home.css';
import HomeBanner from '../Components/HomeBanner';
import React from 'react';
const Home = () => {
return (
<div className="pageWrapper">
<img src={logo} className="App-logo" alt="logo" />
<p className="homeText">GoScrobble is an open source music scrobbling service.</p>
<p className="subHomeText">Supports Spotify, Jellyfin, Navidrome / Subsonic / Airsonic.</p>
<HomeBanner />
</div>
);
}
export default Home;

View File

@ -1,9 +0,0 @@
.loginFields {
width: 100%;
}
.loginButton {
height: 50px;
width: 100%;
margin-top:-5px;
}

View File

@ -1,74 +0,0 @@
import React, { useContext } from 'react';
import '../App.css';
import './Login.css';
import { Button } from 'reactstrap';
import { Formik, Form, Field } from 'formik';
import ScaleLoader from 'react-spinners/ScaleLoader';
import AuthContext from '../Contexts/AuthContext';
import { useHistory } from "react-router";
const Login = () => {
const history = useHistory();
let boolTrue = true;
let { Login, loading, user } = useContext(AuthContext);
if (user) {
history.push("/u/" + user.username);
}
const redirectReset = () => {
history.push("/reset")
}
return (
<div className="pageWrapper">
<h1>
Login
</h1>
<div className="pageBody">
<Formik
initialValues={{ username: '', password: '' }}
onSubmit={values => Login(values)}
>
<Form>
<label>
Email / Username<br/>
<Field
name="username"
type="text"
required={boolTrue}
className="loginFields"
/>
</label>
<br/>
<label>
Password<br/>
<Field
name="password"
type="password"
className="loginFields"
/>
</label>
<br/><br/>
<Button
color="primary"
type="submit"
className="loginButton"
disabled={loading}
>{loading ? <ScaleLoader color="#FFF" size={35} /> : "Login"}</Button>
<br/><br/>
<Button
color="secondary"
type="button"
className="loginButton"
onClick={redirectReset}
disabled={loading}
>Reset Password</Button>
</Form>
</Formik>
</div>
</div>
);
}
export default Login;

View File

@ -1,81 +0,0 @@
import React, { useState, useEffect } from 'react';
import '../App.css';
import './Profile.css';
import ScaleLoader from 'react-spinners/ScaleLoader';
import { getProfile, getTopTracks, getTopArtists } from '../Api/index'
import ScrobbleTable from '../Components/ScrobbleTable'
import TopTable from '../Components/TopTable'
const Profile = (route) => {
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState({});
const [topTracks, setTopTracks] = useState({})
const [topArtists, setTopArtists] = useState({})
let username = false;
if (route && route.match && route.match.params && route.match.params.uuid) {
username = route.match.params.uuid;
} else {
username = false;
}
useEffect(() => {
if (!username) {
return false;
}
getProfile(username)
.then(data => {
setProfile(data);
// Fetch top tracks
getTopTracks(data.uuid)
.then(data => {
setTopTracks(data.tracks)
})
// Fetch top artists
getTopArtists(data.uuid)
.then(data => {
setTopArtists(data.artists)
})
setLoading(false);
})
}, [username])
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
if (!username || !profile.username) {
return (
<div className="pageWrapper">
Unable to fetch user
</div>
)
}
return (
<div className="pageWrapper">
<h1>
{profile.username}'s Profile
</h1>
<div className="pageBody">
<TopTable type="track" items={topTracks} />
<br/>
<TopTable type="artist" items={topArtists} />
<br/>
Last 10 scrobbles<br/>
<ScrobbleTable data={profile.scrobbles}/>
</div>
</div>
);
}
export default Profile;

View File

@ -1,9 +0,0 @@
.registerFields {
width: 100%;
}
.registerButton {
height: 50px;
width: 100%;
margin-top:-5px;
}

View File

@ -1,111 +0,0 @@
import React, { useContext, useState, useEffect } from 'react';
import '../App.css';
import './Register.css';
import { Button } from 'reactstrap';
import ScaleLoader from "react-spinners/ScaleLoader";
import AuthContext from '../Contexts/AuthContext';
import { Formik, Field, Form } from 'formik';
import { useHistory } from 'react-router';
import { getServerInfo } from '../Api/index';
const Register = () => {
const history = useHistory();
let boolTrue = true;
let { Register, user, loading } = useContext(AuthContext);
let [serverInfo, setServerInfo] = useState({ registration_enabled: true });
let [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (user) {
return
}
getServerInfo()
.then(data => {
setServerInfo(data);
setIsLoading(false);
})
}, [user])
if (isLoading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
if (user) {
history.push("/dashboard");
}
return (
<div className="pageWrapper">
{
serverInfo.registration_enabled !== "1" ?
<p>Registration is temporarily disabled. Please try again soon!</p>
:
<div>
<h1>
Register
</h1>
<div className="pageBody">
<Formik
initialValues={{ username: '', email: '', password: '', passwordconfirm: '' }}
onSubmit={async values => Register(values)}
>
<Form>
<label>
Username<br/>
<Field
name="username"
type="text"
required={boolTrue}
className="registerFields"
/>
</label>
<br/>
<label>
Email<br/>
<Field
name="email"
type="email"
required={boolTrue}
className="registerFields"
/>
</label>
<br/>
<label>
Password<br/>
<Field
name="password"
type="password"
required={boolTrue}
className="registerFields"
/>
</label>
<br/>
<label>
Confirm Password<br/>
<Field
name="passwordconfirm"
type="password"
required={boolTrue}
className="registerFields"
/>
</label>
<br/><br/>
<Button
color="primary"
type="submit"
className="registerButton"
disabled={loading}
>{loading ? <ScaleLoader color="#FFF" size={35} /> : "Register"}</Button>
</Form>
</Formik>
</div>
</div>
}
</div>
);
}
export default Register;

View File

@ -1,9 +0,0 @@
.resetFields {
width: 100%;
}
.resetButton {
height: 50px;
width: 100%;
margin-top:-5px;
}

View File

@ -1,152 +0,0 @@
import React, { useState, useEffect, useContext } from 'react';
import '../App.css';
import './Reset.css';
import { Button } from 'reactstrap';
import { Formik, Form, Field } from 'formik';
import ScaleLoader from 'react-spinners/ScaleLoader';
import { validateResetPassword, sendPasswordReset } from '../Api/index';
import AuthContext from '../Contexts/AuthContext';
const Reset = (route) => {
let boolTrue = true;
const [loading, setLoading] = useState(true);
const [reset, setReset] = useState({});
const [sent, setSent] = useState(false);
let { ResetPassword } = useContext(AuthContext);
let reqToken = false;
if (route && route.match && route.match.params && route.match.params.token) {
reqToken = route.match.params.token
}
const sendReset = (values) => {
sendPasswordReset(values).then(() => {
setSent(true);
});
}
useEffect(() => {
if (!reqToken) {
setLoading(false);
return false;
}
validateResetPassword(reqToken)
.then(data => {
setReset(data);
setLoading(false);
})
}, [reqToken])
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
if (sent) {
return (
<div className="pageWrapper">
<h1>
Check your email!
</h1>
</div>
)
}
if (!reqToken) {
return (
<div className="pageWrapper">
<h1>
Reset Password
</h1>
<div className="pageBody">
<Formik
initialValues={{ email: '' }}
onSubmit={values => sendReset(values)}
>
<Form>
<label>
Email<br/>
<Field
name="email"
type="email"
required={boolTrue}
className="loginFields"
/>
</label>
<br/><br/>
<Button
color="primary"
type="submit"
className="loginButton"
disabled={loading}
>{loading ? <ScaleLoader color="#FFF" size={35} /> : "Reset"}</Button>
</Form>
</Formik>
</div>
</div>
)
}
if (reqToken && !reset.valid) {
return (
<div className="pageWrapper">
Invalid Reset Token or Token expired
</div>
)
}
return (
<div className="pageWrapper">
<h1>
Reset Password
</h1>
<div className="resetBody">
<Formik
initialValues={{ password: '', comfirmpassword: '', token: reqToken }}
onSubmit={values => ResetPassword(values)}
>
<Form>
<label>
New Password<br/>
<Field
name="password"
type="password"
required={boolTrue}
className="resetFields"
/>
</label>
<br/>
<label>
Confirm New Password<br/>
<Field
name="comfirmpassword"
type="password"
required={boolTrue}
className="resetFields"
/>
</label>
<Field
name="token"
type="hidden"
className="resetFields"
/>
<br/><br/>
<Button
color="primary"
type="submit"
className="loginButton"
disabled={loading}
>{loading ? <ScaleLoader color="#FFF" size={35} /> : "Reset"}</Button>
</Form>
</Formik>
</div>
</div>
);
}
export default Reset;

View File

@ -1,20 +0,0 @@
import React from 'react';
import '../App.css';
import './Settings.css';
const Settings = () => {
return (
<div className="pageWrapper">
<h1>
Settings
</h1>
<div className="pageBody">
<p>
All the settings
</p>
</div>
</div>
);
}
export default Settings;

View File

@ -1,115 +0,0 @@
import React, { useContext, useState, useEffect } from 'react';
import '../App.css';
import './Track.css';
import TopUserTable from '../Components/TopUserTable';
import ScaleLoader from 'react-spinners/ScaleLoader';
import { getTrack } from '../Api/index'
import { Link } from 'react-router-dom';
import AuthContext from '../Contexts/AuthContext';
const Track = (route) => {
const { user } = useContext(AuthContext);
const [loading, setLoading] = useState(true);
const [track, setTrack] = useState({});
let trackUUID = false;
if (route && route.match && route.match.params && route.match.params.uuid) {
trackUUID = route.match.params.uuid;
} else {
trackUUID = false;
}
useEffect(() => {
if (!trackUUID) {
return false;
}
getTrack(trackUUID)
.then(data => {
setTrack(data);
setLoading(false);
})
}, [trackUUID])
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
if (!trackUUID || !track) {
return (
<div className="pageWrapper">
Unable to fetch user
</div>
)
}
console.log(track)
let length = "0";
if (track.length && track.length !== '') {
length = new Date(track.length * 1000).toISOString().substr(11, 8)
}
let artists = [];
for (let artist of track.artists) {
const row = (
<Link
key={artist.uuid}
to={"/artist/" + artist.uuid}
>{artist.name} </Link>
);
artists.push(row);
}
let albums = [];
for (let album of track.albums) {
const row = (
<Link
key={album.uuid}
to={"/album/" + album.uuid}
>{album.name} </Link>
);
albums.push(row);
}
return (
<div className="pageWrapper">
<h1 style={{margin: 0}}>
{track.name} {user && <Link
key="editbuttonomg"
to={"/track/" + trackUUID + "/edit"}
>edit</Link>}
</h1>
<div className="pageBody">
<div style={{display: `flex`, flexWrap: `wrap`, textAlign: `center`}}>
<div style={{width: `300px`, padding: `0 10px 10px 10px`, textAlign: `left`}}>
<img src={process.env.REACT_APP_API_URL + "/img/" + track.img + "_full.jpg"} alt={track.name} style={{maxWidth: `300px`, maxHeight: `300px`}}/>
</div>
<div style={{width: `290px`, padding: `0 10px 10px 10px`, margin: `0 5px 0 5px`, textAlign: `left`}}>
<span style={{fontSize: '14pt'}}>
{artists}
</span>
<br/>
<span style={{fontSize: '14pt', textDecoration: 'none'}}>
{albums}
</span>
<br/><br/>
{track.mbid && <a rel="noreferrer" target="_blank" href={"https://musicbrainz.org/track/" + track.mbid}>Open on MusicBrainz<br/></a>}
{track.spotify_id && <a rel="noreferrer" target="_blank" href={"https://open.spotify.com/track/" + track.spotify_id}>Open on Spotify<br/></a>}
{length && <span>Track Length: {length}</span>}
</div>
<div style={{width: `290px`, padding: `0 10px 10px 10px`}}>
<h3>Top 10 Scrobblers</h3>
<TopUserTable uuid={track.uuid}/>
</div>
</div>
</div>
</div>
);
}
export default Track;

View File

@ -1,113 +0,0 @@
import React, { useContext, useState, useEffect } from 'react';
import '../App.css';
import './TrackEdit.css';
import { useHistory } from 'react-router-dom';
import ScaleLoader from 'react-spinners/ScaleLoader';
import { getTrack } from '../Api/index'
import { Link } from 'react-router-dom';
import AuthContext from '../Contexts/AuthContext';
const TrackEdit = (route) => {
const history = useHistory();
const { user } = useContext(AuthContext);
const [loading, setLoading] = useState(true);
const [track, setTrack] = useState({});
let trackUUID = false;
if (route && route.match && route.match.params && route.match.params.uuid) {
trackUUID = route.match.params.uuid;
} else {
trackUUID = false;
}
useEffect(() => {
if (!trackUUID) {
return false;
}
getTrack(trackUUID)
.then(data => {
setTrack(data);
setLoading(false);
})
}, [trackUUID])
if (!user) {
history.push("/login")
}
if (user && !user.mod) {
history.push("/Dashboard")
}
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
if (!trackUUID || !track) {
return (
<div className="pageWrapper">
Unable to fetch user
</div>
)
}
console.log(track)
let length = "0";
if (track.length && track.length !== '') {
length = new Date(track.length * 1000).toISOString().substr(11, 8)
}
let artists = [];
for (let artist of track.artists) {
const row = (
<Link
key={artist.uuid}
to={"/artist/" + artist.uuid}
>{artist.name} </Link>
);
artists.push(row);
}
let albums = [];
for (let album of track.albums) {
const row = (
<Link
key={album.uuid}
to={"/album/" + album.uuid}
>{album.name} </Link>
);
albums.push(row);
}
return (
<div className="pageWrapper">
<h1 style={{margin: 0}}>
{track.name} {<Link
key="editbuttonomg"
to={"/track/" + trackUUID}
>unedit</Link>}
</h1>
<div className="pageBody" style={{width: `900px`, textAlign: `center`}}>
<img src={process.env.REACT_APP_API_URL + "/img/" + track.img + "_full.jpg"} alt={track.name} style={{maxWidth: `300px`, maxHeight: `300px`}}/>
<br/>
<label>Primary Artist ({track.artists[0].name}):</label><br/>
<input type="text" value={track.artists[0].uuid} style={{width: `420px`}} disabled="true"/><br/>
<label>Primary Album ({track.albums[0].name})</label><br/>
<input type="text" value={track.albums[0].uuid} style={{width: `420px`}} disabled="true"/><br/>
<br/>
<label>MBID</label><br/>
<input type="text" value={track.mbid} style={{width: `420px`}} /><br/>
<label>Spotify ID</label><br/>
<input type="text" value={track.spotify_id} style={{width: `420px`}} /><br/>
</div>
</div>
);
}
export default TrackEdit;

View File

@ -1,45 +0,0 @@
.userDropdown {
color: #282C34;
font-size: 12pt;
}
.userButton {
height: 50px;
width: 100%;
margin-top:-5px;
}
.modal {
font-size: 12px;
}
.modal > .header {
width: 100%;
border-bottom: 1px solid gray;
font-size: 18px;
text-align: center;
padding: 5px;
}
.modal > .content {
width: 100%;
padding: 10px 5px;
}
.modal > .actions {
width: 100%;
padding: 10px 5px;
margin: auto;
text-align: center;
}
.modal > .close {
cursor: pointer;
position: absolute;
display: block;
padding: 2px 5px;
line-height: 20px;
right: -10px;
top: -10px;
font-size: 24px;
background: #ffffff;
border-radius: 18px;
border: 1px solid #cfcece;
}

View File

@ -1,278 +0,0 @@
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 { Button } from 'reactstrap';
import { Formik, Form, Field } from 'formik';
import { confirmAlert } from 'react-confirm-alert';
import 'react-confirm-alert/src/react-confirm-alert.css';
import {
getUser,
patchUser,
spotifyConnectionRequest,
spotifyDisonnectionRequest,
navidromeDisonnectionRequest,
navidromeConnectionRequest,
} from '../Api/index'
import TimezoneSelect from 'react-timezone-select'
const User = () => {
const history = useHistory();
const { user } = useContext(AuthContext);
const [loading, setLoading] = useState(true);
const [userdata, setUserdata] = useState({});
const updateTimezone = (vals) => {
setUserdata({...userdata, timezone: vals});
patchUser({timezone: vals.value})
}
const resetTokenPopup = () => {
confirmAlert({
title: 'Reset token',
message: 'Resetting your token will require you to update your Jellyfin server / custom scroblers with the new token. Continue?',
buttons: [
{
label: 'Reset',
onClick: () => resetToken()
},
{
label: 'No',
}
]
});
};
const connectNavidromePopup = () => {
confirmAlert({
title: 'Connect Navidrome',
buttons: [
{
label: 'Close',
}
],
childrenElement: () => <Formik
initialValues={{ url: '', username: '', password: '' }}
onSubmit={values => navidromeConnectionRequest(values)}
>
<Form>
<label>
Server URL<br/>
<Field
name="url"
type="text"
/>
</label>
<br/>
<label>
Username<br/>
<Field
name="username"
type="text"
/>
</label>
<br/>
<label>
Password<br/>
<Field
name="password"
type="password"
/>
</label>
<br/><br/>
<Button
color="primary"
type="submit"
className="loginButton"
>Connect</Button>
</Form>
</Formik>,
});
};
const disconnectNavidromePopup = () => {
confirmAlert({
title: 'Disconnect Navidrome',
message: 'Are you sure you want to disconnect your Navidrome connection?',
buttons: [
{
label: 'Disconnect',
onClick: () => navidromeDisonnectionRequest()
},
{
label: 'No',
}
]
});
};
const disconnectSpotifyPopup = () => {
confirmAlert({
title: 'Disconnect Spotify',
message: 'Are you sure you want to disconnect your Spotify account?',
buttons: [
{
label: 'Disconnect',
onClick: () => spotifyDisonnectionRequest()
},
{
label: 'No',
}
]
});
};
const connectJellyfinPopup = () => {
confirmAlert({
title: 'Connect Jellyfin',
message: 'Install the webhook plugin. Add a webhook to {API_URL}/api/v1/ingress/jellyfin?key='+userdata.token
+'\nSet it to only send "Playback Start" and "Songs/Albums"',
buttons: [
{
label: 'Close',
}
]
});
}
const connectOtherPopup = () => {
confirmAlert({
title: 'Connect Jellyfin',
message: 'Endpoint: {API_URL}/api/v1/ingress/multiscrobbler?key='+userdata.token
+'\nNeed to send JSON body with a string array for artists names, album:string, track:string, playDate:timestamp of scrobble, duration:tracklength in seconds',
buttons: [
{
label: 'Close',
}
]
});
}
const resetToken = () => {
setLoading(true);
patchUser({ token: '' })
.then(() => {
getUser()
.then(data => {
setUserdata(data);
setLoading(false);
})
})
}
useEffect(() => {
if (!user) {
return
}
getUser()
.then(data => {
setUserdata(data);
setLoading(false);
})
}, [user])
if (!user) {
history.push("/login")
}
if (loading) {
return (
<div className="pageWrapper">
<ScaleLoader color="#6AD7E5" />
</div>
)
}
return (
<div className="pageWrapper">
<h1>
Welcome {userdata.username}
</h1>
<div style={{display: `flex`, flexWrap: `wrap`, textAlign: `center`}}>
<div style={{width: `300px`, padding: `0 10px 10px 10px`, textAlign: `left`}}>
<h3 style={{textAlign: `center`}}>Profile</h3>
Timezone<br/>
<TimezoneSelect
className="userDropdown"
value={userdata.timezone}
onChange={updateTimezone}
/><br/>
Created At:<br/>{userdata.created_at}<br/>
Email:<br/>{userdata.email}<br/>
Verified: {userdata.verified ? '✓' : '✖'}
</div>
<div style={{width: `300px`, padding: `0 10px 10px 10px`}}>
<h3>Scrobblers</h3>
<br/>
{userdata.spotify_username
? <Button
color="secondary"
type="button"
className="userButton"
onClick={disconnectSpotifyPopup}
>Disconnect Spotify ({userdata.spotify_username})</Button>
: <div>
<br/>
<Button
color="primary"
type="button"
className="userButton"
onClick={spotifyConnectionRequest}
>Connect To Spotify</Button>
</div>
}
<br/><br/>
{userdata.navidrome_server
? <Button
color="secondary"
type="button"
className="userButton"
onClick={disconnectNavidromePopup}
>Disconnect Navidrome ({userdata.navidrome_server})</Button>
: <Button
color="primary"
type="button"
className="userButton"
onClick={connectNavidromePopup}
>Connect Navidrome</Button>
}
<br/><br/>
<Button
color="primary"
type="button"
className="userButton"
onClick={connectJellyfinPopup}
>Connect Jellyfin</Button>
<br/><br/>
<Button
color="primary"
type="button"
className="userButton"
onClick={connectOtherPopup}
>Other Scrobblers</Button>
</div>
<div style={{width: `300px`, padding: `0 10px 10px 10px`}}>
<h3>Sad Settings</h3>
<br/>
<Button
color="secondary"
type="button"
className="userButton"
>Delete Account</Button>
<br/><br/>
<Button
color="secondary"
type="button"
className="userButton"
onClick={resetTokenPopup}
>Reset Scrobbler Token</Button>
</div>
</div>
</div>
);
}
export default User;

View File

@ -1,13 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -1,30 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.min.css';
import AuthContextProvider from './Contexts/AuthContextProvider';
ReactDOM.render(
<AuthContextProvider>
<BrowserRouter>
<ToastContainer
position="bottom-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop={true}
closeOnClick
rtl={false}
pauseOnFocusLoss={false}
draggable
pauseOnHover
/>
<App />
</BrowserRouter>
</AuthContextProvider>,
document.getElementById('root')
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View File

@ -1,13 +0,0 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';