0.2.0 - Mid migration

This commit is contained in:
Daniel Mason 2022-04-25 14:47:15 +12:00
parent 139e6a915e
commit 7e38fdbd7d
42393 changed files with 5358157 additions and 62 deletions

448
web/src/Api/index.js Normal file
View 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
View 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
View 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
View 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();
});

View 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

View 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;
}

View 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;

View 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;
}

View 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;

View 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> -&nbsp;
<Link
key={"artist" + element.time}
to={"/artist/"+element.artist.uuid}
>{element.artist.name}</Link>
&nbsp;by <Link
key={"user" + element.time}
to={"/u/"+element.user.name}
> {element.user.name}</Link>
</div>;
})
}
</div>
);
}
export default RecentScrobbleTable;

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View file

View 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;

View 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;

View file

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

View 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;

View file

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

1
web/src/Pages/About.css Normal file
View file

@ -0,0 +1 @@

25
web/src/Pages/About.js Normal file
View 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
View file

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

112
web/src/Pages/Admin.js Normal file
View 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
View file

81
web/src/Pages/Album.js Normal file
View 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;

View file

View 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
View file

81
web/src/Pages/Artist.js Normal file
View 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;

View file

View 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
View 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
View 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
View file

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

74
web/src/Pages/Login.js Normal file
View 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
View 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
View 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
View file

39
web/src/Pages/Recent.js Normal file
View 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;

View file

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

111
web/src/Pages/Register.js Normal file
View 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
View file

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

152
web/src/Pages/Reset.js Normal file
View 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;

View file

20
web/src/Pages/Settings.js Normal file
View 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
View file

114
web/src/Pages/Track.js Normal file
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View 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
View 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';