mirror of
https://github.com/idanoo/GoScrobble
synced 2025-07-01 21:52:19 +00:00
0.2.0 - Mid migration
This commit is contained in:
parent
139e6a915e
commit
7e38fdbd7d
42393 changed files with 5358157 additions and 62 deletions
448
web/src/Api/index.js
Normal file
448
web/src/Api/index.js
Normal file
|
@ -0,0 +1,448 @@
|
|||
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.resolve();
|
||||
}
|
||||
}).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 uploadImage = (values, type, uuid, history) => {
|
||||
return axios.post(process.env.REACT_APP_API_URL + "/api/v1/"+type+"s/"+uuid+"/upload", values, { headers: getHeaders() })
|
||||
.then((response) => {
|
||||
if (response.data.message) {
|
||||
toast.success(response.data.message);
|
||||
|
||||
// Hacky (:
|
||||
history.push("/"+type+"/"+uuid);
|
||||
window.location.reload();
|
||||
|
||||
|
||||
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 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 getRecentScrobblesForUser = (uuid) => {
|
||||
// return axios.get(process.env.REACT_APP_API_URL + "/api/v1/user/" + uuid + "/scrobbles", { headers: getHeaders() })
|
||||
// .then((data) => {
|
||||
// return data.data;
|
||||
// }).catch((error) => {
|
||||
// return handleErrorResp(error)
|
||||
// });
|
||||
// };
|
||||
|
||||
export const getRecentScrobbles = () => {
|
||||
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/recent")
|
||||
.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, days) => {
|
||||
let url = process.env.REACT_APP_API_URL + "/api/v1/tracks/top/" + uuid
|
||||
if (days) {
|
||||
url = url + "/" + days
|
||||
}
|
||||
|
||||
return axios.get(url).then(
|
||||
(data) => {
|
||||
return data.data;
|
||||
}).catch((error) => {
|
||||
return handleErrorResp(error)
|
||||
});
|
||||
}
|
||||
|
||||
export const getArtistTracks = (uuid) => {
|
||||
let url = process.env.REACT_APP_API_URL + "/api/v1/tracks/artist/" + uuid
|
||||
|
||||
return axios.get(url).then(
|
||||
(data) => {
|
||||
return data.data;
|
||||
}).catch((error) => {
|
||||
return handleErrorResp(error)
|
||||
});
|
||||
}
|
||||
|
||||
export const getAlbumTracks = (uuid) => {
|
||||
let url = process.env.REACT_APP_API_URL + "/api/v1/tracks/album/" + uuid
|
||||
|
||||
return axios.get(url).then(
|
||||
(data) => {
|
||||
return data.data;
|
||||
}).catch((error) => {
|
||||
return handleErrorResp(error)
|
||||
});
|
||||
}
|
||||
|
||||
export const getTopArtists = (uuid, days) => {
|
||||
let url = process.env.REACT_APP_API_URL + "/api/v1/artists/top/" + uuid
|
||||
if (days) {
|
||||
url = url + "/" + days
|
||||
}
|
||||
return axios.get(url).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)
|
||||
});
|
||||
}
|
||||
|
||||
export const getTopUsersForAlbum = (uuid) => {
|
||||
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/albums/" + uuid + "/top").then(
|
||||
(data) => {
|
||||
return data.data;
|
||||
}).catch((error) => {
|
||||
return handleErrorResp(error)
|
||||
});
|
||||
}
|
||||
|
||||
export const getTopUsersForArtist = (uuid) => {
|
||||
return axios.get(process.env.REACT_APP_API_URL + "/api/v1/artists/" + uuid + "/top").then(
|
||||
(data) => {
|
||||
return data.data;
|
||||
}).catch((error) => {
|
||||
return handleErrorResp(error)
|
||||
});
|
||||
}
|
68
web/src/App.css
Normal file
68
web/src/App.css
Normal file
|
@ -0,0 +1,68 @@
|
|||
html, body {
|
||||
background-color: #282c34;
|
||||
/** WHY DOES THIS DEFAULT TO 1.5 */
|
||||
line-height: 1.3!important;
|
||||
}
|
||||
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 20vmin;
|
||||
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);
|
||||
}
|
||||
}
|
61
web/src/App.js
Normal file
61
web/src/App.js
Normal file
|
@ -0,0 +1,61 @@
|
|||
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 AlbumEdit from './Pages/AlbumEdit';
|
||||
import ArtistEdit from './Pages/ArtistEdit';
|
||||
import Track from './Pages/Track';
|
||||
import Recent from './Pages/Recent';
|
||||
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="/recent" component={Recent} />
|
||||
|
||||
<Route path="/user" component={User} />
|
||||
<Route path="/u/:uuid" component={Profile} />
|
||||
<Route path="/artist/:uuid/edit" component={ArtistEdit} />
|
||||
<Route path="/artist/:uuid" component={Artist} />
|
||||
<Route path="/album/:uuid/edit" component={AlbumEdit} />
|
||||
<Route path="/album/:uuid" component={Album} />
|
||||
<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);
|
8
web/src/App.test.js
Normal file
8
web/src/App.test.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
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();
|
||||
});
|
16
web/src/Components/FileUploader.js
Normal file
16
web/src/Components/FileUploader.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import React from 'react'
|
||||
|
||||
const FileUploader = ({onFileSelect}) => {
|
||||
const handleFileInput = (e) => {
|
||||
// handle validations here in future
|
||||
onFileSelect(e.target.files[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<span>
|
||||
<input type="file" onChange={handleFileInput} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUploader
|
26
web/src/Components/HomeBanner.css
Normal file
26
web/src/Components/HomeBanner.css
Normal file
|
@ -0,0 +1,26 @@
|
|||
.homeBanner {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 50px;
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.homeBannerItem {
|
||||
float: left;
|
||||
text-align: center;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.homeBannerItemLink {
|
||||
color: #FFFFFF;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.homeBannerItemLink:hover {
|
||||
color: #666666;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.homeBannerItemCount {
|
||||
font-size: 1.9rem;
|
||||
}
|
59
web/src/Components/HomeBanner.js
Normal file
59
web/src/Components/HomeBanner.js
Normal file
|
@ -0,0 +1,59 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import '../App.css';
|
||||
import './HomeBanner.css';
|
||||
import { getStats } from '../Api/index';
|
||||
import ClipLoader from 'react-spinners/ClipLoader'
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const HomeBanner = () => {
|
||||
let [bannerData, setBannerData] = useState({});
|
||||
let [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getStats()
|
||||
.then(data => {
|
||||
if (data.users !== undefined) {
|
||||
setBannerData(data);
|
||||
setIsLoading(false);
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="homeBanner">
|
||||
<div className="homeBannerItem">
|
||||
<Link to="/recent" className="homeBannerItemLink">
|
||||
{isLoading
|
||||
? <ClipLoader color="#6AD7E5" size={34} />
|
||||
: <span className="homeBannerItemCount">{bannerData.scrobbles}</span>
|
||||
|
||||
}
|
||||
<br/>Scrobbles
|
||||
</Link>
|
||||
</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;
|
45
web/src/Components/Navigation.css
Normal file
45
web/src/Components/Navigation.css
Normal file
|
@ -0,0 +1,45 @@
|
|||
.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;
|
||||
}
|
||||
|
||||
.nav-logo-link {
|
||||
color: #FFFFFF;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-logo-link:hover {
|
||||
color: #FFFFFF;
|
||||
text-decoration: none;
|
||||
}
|
184
web/src/Components/Navigation.js
Normal file
184
web/src/Components/Navigation.js
Normal file
|
@ -0,0 +1,184 @@
|
|||
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',
|
||||
'Recent',
|
||||
// 'About',
|
||||
];
|
||||
|
||||
const loggedInMenuItems = [
|
||||
'Home',
|
||||
'Recent',
|
||||
'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"><Link className="nav-logo-link" to="/"><img src={logo} className="nav-logo" alt="logo" /> GoScrobble</Link></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"><Link className="nav-logo-link" to="/"><img src={logo} className="nav-logo" alt="logo" /> GoScrobble</Link></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;
|
40
web/src/Components/RecentScrobbleTable.js
Normal file
40
web/src/Components/RecentScrobbleTable.js
Normal file
|
@ -0,0 +1,40 @@
|
|||
import React from "react";
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const RecentScrobbleTable = (props) => {
|
||||
return (
|
||||
<div style={{
|
||||
border: `1px solid #FFFFFF`,
|
||||
width: `100%`,
|
||||
display: `flex`,
|
||||
flexWrap: `wrap`,
|
||||
minWidth: `300px`,
|
||||
maxWidth: `1200px`,
|
||||
}}>
|
||||
{
|
||||
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()}
|
||||
<Link
|
||||
key={"track" + element.time}
|
||||
to={"/track/"+element.track.uuid}
|
||||
> {element.track.name}</Link> -
|
||||
<Link
|
||||
key={"artist" + element.time}
|
||||
to={"/artist/"+element.artist.uuid}
|
||||
>{element.artist.name}</Link>
|
||||
by <Link
|
||||
key={"user" + element.time}
|
||||
to={"/u/"+element.user.name}
|
||||
> {element.user.name}</Link>
|
||||
</div>;
|
||||
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecentScrobbleTable;
|
35
web/src/Components/ScrobbleTable.js
Normal file
35
web/src/Components/ScrobbleTable.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
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;
|
13
web/src/Components/TopTable.css
Normal file
13
web/src/Components/TopTable.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
.biggestWrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.biggestBox {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
146
web/src/Components/TopTable.js
Normal file
146
web/src/Components/TopTable.js
Normal file
|
@ -0,0 +1,146 @@
|
|||
import React from "react";
|
||||
import './TopTable.css'
|
||||
import TopTableBox from './TopTableBox';
|
||||
import ClipLoader from 'react-spinners/ClipLoader'
|
||||
|
||||
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;
|
||||
|
||||
if (props.loading) {
|
||||
return <div style={{textAlign: `center`}}>
|
||||
<span>Top {props.type}s</span>
|
||||
<div className="biggestWrapper">
|
||||
<div className="biggestBox"></div>
|
||||
<div className="biggestBox">
|
||||
<div style={{padding: `80px`}}>
|
||||
<ClipLoader color="#6AD7E5" size={150} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="biggestBox"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<div style={{textAlign: `center`}}>
|
||||
<span>Top {props.type}s {props.extraText && props.extraText}</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;
|
22
web/src/Components/TopTableBox.css
Normal file
22
web/src/Components/TopTableBox.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
.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;
|
||||
}
|
29
web/src/Components/TopTableBox.js
Normal file
29
web/src/Components/TopTableBox.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
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 + "_300px.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;
|
0
web/src/Components/TopUserTable.css
Normal file
0
web/src/Components/TopUserTable.css
Normal file
70
web/src/Components/TopUserTable.js
Normal file
70
web/src/Components/TopUserTable.js
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import './TopUserTable.css'
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ScaleLoader from 'react-spinners/ScaleLoader';
|
||||
import { getTopUsersForTrack, getTopUsersForAlbum, getTopUsersForArtist } from '../Api/index'
|
||||
|
||||
const TopUserTable = (props) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState({});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.trackuuid && !props.albumuuid && !props.artistuuid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (props.trackuuid) {
|
||||
getTopUsersForTrack(props.trackuuid)
|
||||
.then(data => {
|
||||
setData(data);
|
||||
setLoading(false);
|
||||
})
|
||||
} else if (props.albumuuid) {
|
||||
getTopUsersForAlbum(props.albumuuid)
|
||||
.then(data => {
|
||||
setData(data);
|
||||
setLoading(false);
|
||||
})
|
||||
} else if (props.artistuuid) {
|
||||
getTopUsersForArtist(props.artistuuid)
|
||||
.then(data => {
|
||||
setData(data);
|
||||
setLoading(false);
|
||||
})
|
||||
}
|
||||
}, [props.trackuuid, props.albumuuid, props.artistuuid])
|
||||
|
||||
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" + element.user_uuid}>
|
||||
<Link
|
||||
key={"user" + element.user_uuid}
|
||||
to={"/u/"+element.user_name}
|
||||
>{element.user_name}</Link> ({element.count})
|
||||
</div>;
|
||||
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopUserTable;
|
65
web/src/Components/TracksForRecordTable.js
Normal file
65
web/src/Components/TracksForRecordTable.js
Normal file
|
@ -0,0 +1,65 @@
|
|||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getAlbumTracks, getArtistTracks } from '../Api/index'
|
||||
import ScaleLoader from 'react-spinners/ScaleLoader';
|
||||
|
||||
const TracksForRecordTable = (props) => {
|
||||
const [tracks, setTracks] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.albumuuid !== undefined) {
|
||||
getAlbumTracks(props.albumuuid)
|
||||
.then(data => {
|
||||
setTracks(data);
|
||||
setLoading(false);
|
||||
})
|
||||
} else if (props.artistuuid !== undefined) {
|
||||
getArtistTracks(props.artistuuid)
|
||||
.then(data => {
|
||||
setTracks(data);
|
||||
setLoading(false);
|
||||
})
|
||||
}
|
||||
}, [props.albumuuid, props.artistuuid]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<ScaleLoader color="#6AD7E5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
console.log(tracks);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
textAlign: `center`,
|
||||
}}>Tracks<br/>
|
||||
<div style={{
|
||||
border: `1px solid #FFFFFF`,
|
||||
width: `100%`,
|
||||
display: `flex`,
|
||||
flexWrap: `wrap`,
|
||||
minWidth: `300px`,
|
||||
maxWidth: `1200px`,
|
||||
}}>
|
||||
{
|
||||
tracks && tracks.tracks &&
|
||||
Object.keys(tracks.tracks).map(key => {
|
||||
return <div style={{borderBottom: `1px solid #CCC`, width: `100%`, padding: `2px`}} key={"box" + tracks.tracks[key].uuid}>
|
||||
<Link
|
||||
key={"track" + tracks.tracks[key].uuid}
|
||||
to={"/track/"+tracks.tracks[key].uuid}
|
||||
> {tracks.tracks[key].name}</Link>
|
||||
</div>;
|
||||
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TracksForRecordTable;
|
5
web/src/Contexts/AuthContext.js
Normal file
5
web/src/Contexts/AuthContext.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import React from 'react';
|
||||
|
||||
const AuthContext = React.createContext();
|
||||
|
||||
export default AuthContext;
|
81
web/src/Contexts/AuthContextProvider.js
Normal file
81
web/src/Contexts/AuthContextProvider.js
Normal file
|
@ -0,0 +1,81 @@
|
|||
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;
|
3
web/src/Helpers/history.js
Normal file
3
web/src/Helpers/history.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { createBrowserHistory } from "history";
|
||||
|
||||
export const history = createBrowserHistory();
|
1
web/src/Pages/About.css
Normal file
1
web/src/Pages/About.css
Normal file
|
@ -0,0 +1 @@
|
|||
|
25
web/src/Pages/About.js
Normal file
25
web/src/Pages/About.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
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/goscrobble"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>gitlab.com/idanoo/goscrobble
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default About;
|
9
web/src/Pages/Admin.css
Normal file
9
web/src/Pages/Admin.css
Normal file
|
@ -0,0 +1,9 @@
|
|||
.adminFields {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
margin-top:-5px;
|
||||
}
|
112
web/src/Pages/Admin.js
Normal file
112
web/src/Pages/Admin.js
Normal file
|
@ -0,0 +1,112 @@
|
|||
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_API_ID"
|
||||
type="text"
|
||||
className="loginFields"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Spotify App Secret<br/>
|
||||
<Field
|
||||
name="SPOTIFY_API_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;
|
0
web/src/Pages/Album.css
Normal file
0
web/src/Pages/Album.css
Normal file
81
web/src/Pages/Album.js
Normal file
81
web/src/Pages/Album.js
Normal file
|
@ -0,0 +1,81 @@
|
|||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import '../App.css';
|
||||
import './Album.css';
|
||||
import ScaleLoader from 'react-spinners/ScaleLoader';
|
||||
import { getAlbum } from '../Api/index'
|
||||
import TopUserTable from '../Components/TopUserTable';
|
||||
import TracksForRecordTable from '../Components/TracksForRecordTable';
|
||||
import AuthContext from '../Contexts/AuthContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const Album = (route) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [album, setAlbum] = useState({});
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
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 album
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<h1 style={{margin: 0}}>
|
||||
{album.name} {user && user.mod && <Link
|
||||
key="editbuttonomg"
|
||||
to={"/album/" + album.uuid + "/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/" + album.uuid + "_full.jpg"} alt={album.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'}}>
|
||||
{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>}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{width: `290px`, padding: `0 10px 10px 10px`}}>
|
||||
<h3>Top 10 Scrobblers</h3>
|
||||
<TopUserTable albumuuid={album.uuid}/>
|
||||
</div>
|
||||
</div>
|
||||
<TracksForRecordTable albumuuid={album.uuid}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Album;
|
0
web/src/Pages/AlbumEdit.css
Normal file
0
web/src/Pages/AlbumEdit.css
Normal file
96
web/src/Pages/AlbumEdit.js
Normal file
96
web/src/Pages/AlbumEdit.js
Normal file
|
@ -0,0 +1,96 @@
|
|||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import '../App.css';
|
||||
import './AlbumEdit.css';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import ScaleLoader from 'react-spinners/ScaleLoader';
|
||||
import { getAlbum, uploadImage } from '../Api/index'
|
||||
import { Link } from 'react-router-dom';
|
||||
import AuthContext from '../Contexts/AuthContext';
|
||||
import FileUploader from '../Components/FileUploader';
|
||||
|
||||
const AlbumEdit = (route) => {
|
||||
const history = useHistory();
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [album, setAlbum] = useState({});
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
|
||||
const submitForm = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("name", "file");
|
||||
formData.append("file", selectedFile);
|
||||
|
||||
uploadImage(formData, "album", album.uuid, history)
|
||||
};
|
||||
|
||||
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 (!user) {
|
||||
history.push("/login")
|
||||
}
|
||||
|
||||
if (user && !user.mod) {
|
||||
history.push("/Dashboard")
|
||||
}
|
||||
|
||||
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 style={{margin: 0}}>
|
||||
{album.name} {<Link
|
||||
key="editbuttonomg"
|
||||
to={"/album/" + albumUUID}
|
||||
>unedit</Link>}
|
||||
</h1>
|
||||
<div className="pageBody" style={{width: `900px`, textAlign: `center`}}>
|
||||
<img src={process.env.REACT_APP_API_URL + "/img/" + album.uuid + "_full.jpg"} alt={album.name} style={{maxWidth: `300px`, maxHeight: `300px`}}/>
|
||||
|
||||
<form>
|
||||
<FileUploader
|
||||
onFileSelect={(file) => setSelectedFile(file)}
|
||||
/>
|
||||
<button onClick={submitForm}>Submit</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumEdit;
|
0
web/src/Pages/Artist.css
Normal file
0
web/src/Pages/Artist.css
Normal file
81
web/src/Pages/Artist.js
Normal file
81
web/src/Pages/Artist.js
Normal file
|
@ -0,0 +1,81 @@
|
|||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import '../App.css';
|
||||
import './Artist.css';
|
||||
import ScaleLoader from 'react-spinners/ScaleLoader';
|
||||
import { getArtist } from '../Api/index'
|
||||
import TracksForRecordTable from '../Components/TracksForRecordTable';
|
||||
import TopUserTable from '../Components/TopUserTable';
|
||||
import AuthContext from '../Contexts/AuthContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const Artist = (route) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [artist, setArtist] = useState({});
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
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 artist
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<h1 style={{margin: 0}}>
|
||||
{artist.name} {user && <Link
|
||||
key="editbuttonomg"
|
||||
to={"/artist/" + artist.uuid + "/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/" + artist.uuid + "_full.jpg"} alt={artist.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'}}>
|
||||
{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>}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{width: `290px`, padding: `0 10px 10px 10px`}}>
|
||||
<h3>Top 10 Scrobblers</h3>
|
||||
<TopUserTable artistuuid={artist.uuid}/>
|
||||
</div>
|
||||
</div>
|
||||
<TracksForRecordTable artistuuid={artist.uuid}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Artist;
|
0
web/src/Pages/ArtistEdit.css
Normal file
0
web/src/Pages/ArtistEdit.css
Normal file
96
web/src/Pages/ArtistEdit.js
Normal file
96
web/src/Pages/ArtistEdit.js
Normal file
|
@ -0,0 +1,96 @@
|
|||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import '../App.css';
|
||||
import './ArtistEdit.css';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import ScaleLoader from 'react-spinners/ScaleLoader';
|
||||
import { getArtist, uploadImage } from '../Api/index'
|
||||
import { Link } from 'react-router-dom';
|
||||
import AuthContext from '../Contexts/AuthContext';
|
||||
import FileUploader from '../Components/FileUploader';
|
||||
|
||||
const ArtistEdit = (route) => {
|
||||
const history = useHistory();
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [artist, setArtist] = useState({});
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
|
||||
const submitForm = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("name", "file");
|
||||
formData.append("file", selectedFile);
|
||||
|
||||
uploadImage(formData, "artist", artist.uuid, history)
|
||||
};
|
||||
|
||||
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 (!user) {
|
||||
history.push("/login")
|
||||
}
|
||||
|
||||
if (user && !user.mod) {
|
||||
history.push("/Dashboard")
|
||||
}
|
||||
|
||||
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 style={{margin: 0}}>
|
||||
{artist.name} {<Link
|
||||
key="editbuttonomg"
|
||||
to={"/artist/" + artistUUID}
|
||||
>unedit</Link>}
|
||||
</h1>
|
||||
<div className="pageBody" style={{width: `900px`, textAlign: `center`}}>
|
||||
<img src={process.env.REACT_APP_API_URL + "/img/" + artist.uuid + "_full.jpg"} alt={artist.name} style={{maxWidth: `300px`, maxHeight: `300px`}}/>
|
||||
|
||||
<form>
|
||||
<FileUploader
|
||||
onFileSelect={(file) => setSelectedFile(file)}
|
||||
/>
|
||||
<button onClick={submitForm}>Submit</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArtistEdit;
|
19
web/src/Pages/Home.css
Normal file
19
web/src/Pages/Home.css
Normal file
|
@ -0,0 +1,19 @@
|
|||
.homeText {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.subHomeText {
|
||||
margin-top: -5px;
|
||||
font-style: italic;
|
||||
color: #CCC;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.homeContainer {
|
||||
display: flex; /* or inline-flex */
|
||||
}
|
||||
|
||||
.homeItem {
|
||||
padding-top: 2em;
|
||||
}
|
52
web/src/Pages/Home.js
Normal file
52
web/src/Pages/Home.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
import logo from '../logo.png';
|
||||
import '../App.css';
|
||||
import './Home.css';
|
||||
import HomeBanner from '../Components/HomeBanner';
|
||||
import TopTable from '../Components/TopTable';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getTopTracks, getTopArtists } from '../Api/index'
|
||||
|
||||
const Home = () => {
|
||||
const [topArtists, setTopArtists] = useState({})
|
||||
const [topTracks, setTopTracks] = useState({})
|
||||
const [tableLoading, setTableLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch top tracks
|
||||
// if (topTracks && Object.keys(topTracks).length === 0) {
|
||||
// getTopTracks("0", 7)
|
||||
// .then(data => {
|
||||
// setTopTracks(data.tracks)
|
||||
// })
|
||||
// }
|
||||
|
||||
// Fetch top artists
|
||||
if (topArtists && Object.keys(topArtists).length === 0) {
|
||||
getTopArtists("0", 7)
|
||||
.then(data => {
|
||||
setTopArtists(data.artists)
|
||||
})
|
||||
}
|
||||
|
||||
setTableLoading(false);
|
||||
}, [topTracks, topArtists])
|
||||
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<div className="homeContainer">
|
||||
<div>
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
</div>
|
||||
<div className="homeItem">
|
||||
<p className="homeText">GoScrobble is an open source music scrobbling service.</p>
|
||||
<p className="subHomeText">Supports Spotify, Jellyfin, Navidrome / Subsonic / Airsonic.</p>
|
||||
</div>
|
||||
</div>
|
||||
<HomeBanner />
|
||||
{/* <TopTable type="track" items={topTracks} loading={tableLoading} /> */}
|
||||
<TopTable type="artist" items={topArtists} loading={tableLoading} extraText="this week" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
9
web/src/Pages/Login.css
Normal file
9
web/src/Pages/Login.css
Normal file
|
@ -0,0 +1,9 @@
|
|||
.loginFields {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loginButton {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
margin-top:-5px;
|
||||
}
|
74
web/src/Pages/Login.js
Normal file
74
web/src/Pages/Login.js
Normal file
|
@ -0,0 +1,74 @@
|
|||
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;
|
18
web/src/Pages/Profile.css
Normal file
18
web/src/Pages/Profile.css
Normal file
|
@ -0,0 +1,18 @@
|
|||
.profileDateRange {
|
||||
text-align: center;
|
||||
margin: -10px 0 15px 0;
|
||||
font-size: 0.9em;
|
||||
color: #CCCCCC;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profileDateRangeText {
|
||||
margin: 0 10px 0 10px;
|
||||
}
|
||||
.profileDateRangeText:hover {
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.profileDateRangeActive {
|
||||
color: #FFFFFF!important;
|
||||
}
|
127
web/src/Pages/Profile.js
Normal file
127
web/src/Pages/Profile.js
Normal file
|
@ -0,0 +1,127 @@
|
|||
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 profileDateRanges = {
|
||||
'All time': false,
|
||||
'Last year': '365',
|
||||
'Last month': '30',
|
||||
'Last week': '7',
|
||||
};
|
||||
|
||||
const defaultDateRange = 'Last month';
|
||||
let activeStyle = { color: '#FFFFFF' };
|
||||
|
||||
const Profile = (route) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tableLoading, setTableLoading] = useState(false);
|
||||
const [active, setActive] = useState(defaultDateRange);
|
||||
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, profileDateRanges[defaultDateRange])
|
||||
.then(data => {
|
||||
setTopTracks(data.tracks)
|
||||
})
|
||||
|
||||
// Fetch top artists
|
||||
getTopArtists(data.uuid, profileDateRanges[defaultDateRange])
|
||||
.then(data => {
|
||||
setTopArtists(data.artists)
|
||||
})
|
||||
|
||||
setLoading(false);
|
||||
})
|
||||
|
||||
}, [username])
|
||||
|
||||
const reloadScrobblesForDate = (username, days, name) => {
|
||||
setActive(name);
|
||||
setTableLoading(true);
|
||||
getProfile(username)
|
||||
.then(data => {
|
||||
setProfile(data);
|
||||
|
||||
// Fetch top tracks
|
||||
getTopTracks(data.uuid, days)
|
||||
.then(data => {
|
||||
setTopTracks(data.tracks);
|
||||
})
|
||||
|
||||
// Fetch top artists
|
||||
getTopArtists(data.uuid, days)
|
||||
.then(data => {
|
||||
setTopArtists(data.artists);
|
||||
})
|
||||
|
||||
setTableLoading(false)
|
||||
})
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="profileDateRange">
|
||||
{
|
||||
Object.entries(profileDateRanges).map((t,k) => <span>
|
||||
<span onClick={() => reloadScrobblesForDate(username,t[1], t[0])}
|
||||
style={active === t[0] ? activeStyle : {}}
|
||||
className="profileDateRangeText">
|
||||
{t[0]}
|
||||
</span>
|
||||
</span>)
|
||||
}
|
||||
</div>
|
||||
<TopTable type="track" items={topTracks} loading={tableLoading} />
|
||||
<br/>
|
||||
<TopTable type="artist" items={topArtists} loading={tableLoading} />
|
||||
<br/>
|
||||
Last 10 scrobbles<br/>
|
||||
<ScrobbleTable data={profile.scrobbles}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Profile;
|
0
web/src/Pages/Recent.css
Normal file
0
web/src/Pages/Recent.css
Normal file
39
web/src/Pages/Recent.js
Normal file
39
web/src/Pages/Recent.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import '../App.css';
|
||||
import './Recent.css';
|
||||
import RecentScrobbleTable from '../Components/RecentScrobbleTable'
|
||||
import { getRecentScrobbles } from '../Api/index'
|
||||
import ScaleLoader from 'react-spinners/ScaleLoader';
|
||||
|
||||
const Recent = (route) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
getRecentScrobbles()
|
||||
.then(data => {
|
||||
setData(data);
|
||||
setLoading(false);
|
||||
})
|
||||
}
|
||||
|
||||
}, [loading])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
<ScaleLoader color="#6AD7E5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageWrapper">
|
||||
Last 50 Scrobbles
|
||||
<RecentScrobbleTable data={data.items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Recent;
|
9
web/src/Pages/Register.css
Normal file
9
web/src/Pages/Register.css
Normal file
|
@ -0,0 +1,9 @@
|
|||
.registerFields {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.registerButton {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
margin-top:-5px;
|
||||
}
|
111
web/src/Pages/Register.js
Normal file
111
web/src/Pages/Register.js
Normal file
|
@ -0,0 +1,111 @@
|
|||
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;
|
9
web/src/Pages/Reset.css
Normal file
9
web/src/Pages/Reset.css
Normal file
|
@ -0,0 +1,9 @@
|
|||
.resetFields {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
margin-top:-5px;
|
||||
}
|
152
web/src/Pages/Reset.js
Normal file
152
web/src/Pages/Reset.js
Normal file
|
@ -0,0 +1,152 @@
|
|||
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;
|
0
web/src/Pages/Settings.css
Normal file
0
web/src/Pages/Settings.css
Normal file
20
web/src/Pages/Settings.js
Normal file
20
web/src/Pages/Settings.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
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;
|
0
web/src/Pages/Track.css
Normal file
0
web/src/Pages/Track.css
Normal file
114
web/src/Pages/Track.js
Normal file
114
web/src/Pages/Track.js
Normal file
|
@ -0,0 +1,114 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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 && false && <Link
|
||||
key="editbuttonomg"
|
||||
to={"/track/" + track.uuid + "/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 trackuuid={track.uuid}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Track;
|
45
web/src/Pages/User.css
Normal file
45
web/src/Pages/User.css
Normal file
|
@ -0,0 +1,45 @@
|
|||
.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;
|
||||
}
|
308
web/src/Pages/User.js
Normal file
308
web/src/Pages/User.js
Normal file
|
@ -0,0 +1,308 @@
|
|||
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, Logout } = 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 deleteAccountPopup = () => {
|
||||
confirmAlert({
|
||||
title: 'Delete Account',
|
||||
message: 'This will disable your account and queue it for deletion. Are you sure?',
|
||||
buttons: [
|
||||
{
|
||||
label: 'Yes',
|
||||
onClick: () => deleteAccount()
|
||||
},
|
||||
{
|
||||
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 ' + process.env.REACT_APP_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: ' + process.env.REACT_APP_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);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const deleteAccount = () => {
|
||||
setLoading(true);
|
||||
patchUser({ active: 0 })
|
||||
.then(() => {
|
||||
getUser()
|
||||
.then(data => {
|
||||
setUserdata(data);
|
||||
Logout();
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
? <div>
|
||||
<Button
|
||||
color="secondary"
|
||||
type="button"
|
||||
className="userButton"
|
||||
onClick={disconnectSpotifyPopup}
|
||||
>Disconnect Spotify ({userdata.spotify_username})</Button>
|
||||
</div>
|
||||
: <div>
|
||||
<Button
|
||||
color="primary"
|
||||
type="button"
|
||||
className="userButton"
|
||||
onClick={spotifyConnectionRequest}
|
||||
>Connect To Spotify</Button>
|
||||
</div>
|
||||
}
|
||||
<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"
|
||||
onClick={deleteAccountPopup}
|
||||
>Disable Account</Button>
|
||||
<br/><br/>
|
||||
<Button
|
||||
color="secondary"
|
||||
type="button"
|
||||
className="userButton"
|
||||
onClick={resetTokenPopup}
|
||||
>Reset Scrobbler Token</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default User;
|
13
web/src/index.css
Normal file
13
web/src/index.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
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;
|
||||
}
|
30
web/src/index.js
Normal file
30
web/src/index.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
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')
|
||||
);
|
BIN
web/src/logo.png
Normal file
BIN
web/src/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 51 KiB |
13
web/src/reportWebVitals.js
Normal file
13
web/src/reportWebVitals.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
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;
|
5
web/src/setupTests.js
Normal file
5
web/src/setupTests.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
// 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';
|
Loading…
Add table
Add a link
Reference in a new issue