updated chart.js, webpack. added js docs, added js to sphinx

This commit is contained in:
andy 2021-06-11 16:36:01 +01:00
parent a33d91dba4
commit 4fc4676041
38 changed files with 4001 additions and 5166 deletions

View File

@ -83,6 +83,20 @@ jobs:
- name: Install Python Dependencies - name: Install Python Dependencies
run: poetry install run: poetry install
# JS setup for jsdoc
- name: Install Node ${{ matrix.node }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
# JS setup for jsdoc
- name: Install jsdoc
run: npm install jsdoc
# JS setup for jsdoc
- name: Add node_modules/.bin to PATH
run: echo "${GITHUB_WORKSPACE}/node_modules/.bin" >> $GITHUB_PATH
# DEPLOY for setting up cloud API # DEPLOY for setting up cloud API
- name: Set up Cloud SDK - name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@master uses: google-github-actions/setup-gcloud@master

View File

@ -28,7 +28,10 @@ author = 'Sarsoo'
# Add any Sphinx extension module names here, as strings. They can be # Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.napoleon'] extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.napoleon', 'sphinx_js']
js_source_path = '../src/js'
jsdoc_config_path = 'jsdoc.json'
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates'] templates_path = ['_templates']

View File

@ -13,6 +13,7 @@ Music Tools
src/music.db src/music.db
src/music.model src/music.model
src/music.tasks src/music.tasks
src/MusicTools
`Music Tools <https://music.sarsoo.xyz>`_ `Music Tools <https://music.sarsoo.xyz>`_
---------------------------------------------- ----------------------------------------------

5
docs/jsdoc.json Normal file
View File

@ -0,0 +1,5 @@
{
"opts": {
"recurse": true
}
}

View File

@ -0,0 +1,6 @@
MusicTools
=================
.. js:autoclass:: MusicTools
:members:
:private-members:

View File

@ -0,0 +1,61 @@
Playlist
=================
Router
--------
.. js:autoclass:: Playlists
:members:
:private-members:
Playlists List
------------------
.. js:autoclass:: PlaylistsView
:members:
:private-members:
.. js:autoclass:: PlaylistGrid
:members:
:private-members:
.. js:autoclass:: PlaylistCard
:members:
:private-members:
.. js:autofunction:: getPlaylistLink
New Playlist Card
--------------------
.. js:autoclass:: NewPlaylist
:members:
:private-members:
Playlist Router
------------------
.. js:autoclass:: PlaylistRouter.View
:members:
:private-members:
Playlist View
------------------
.. js:autoclass:: Edit
:members:
:private-members:
.. js:autofunction:: ReferenceEntry
.. js:autofunction:: Edit.ListBlock
.. js:autofunction:: Edit.BlockGridItem
Playlist Stats View
-----------------------
.. js:autoclass:: Count
:members:
:private-members:

View File

@ -0,0 +1,30 @@
Tag
=================
Router
--------
.. js:autoclass:: TagRouter
:members:
:private-members:
Tags List
------------------
.. js:autoclass:: TagList
:members:
:private-members:
.. js:autofunction:: TagGrid
.. js:autofunction:: TagCard
.. js:autofunction:: getTagLink
New Tag Card
--------------------
.. js:autoclass:: NewTag
:members:
:private-members:

13
docs/src/MusicTools.rst Normal file
View File

@ -0,0 +1,13 @@
Music Tools React
===================
Subpackages
-----------
.. toctree::
:maxdepth: 4
MusicTools.MusicTools
MusicTools.Playlist
MusicTools.Tag

View File

@ -5,3 +5,4 @@ music
:maxdepth: 4 :maxdepth: 4
music music
MusicTools

8248
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,9 +22,9 @@
"@material-ui/core": "^4.11.3", "@material-ui/core": "^4.11.3",
"@material-ui/icons": "^4.11.2", "@material-ui/icons": "^4.11.2",
"axios": "^0.21.1", "axios": "^0.21.1",
"chart.js": "^2.9.4", "chart.js": "^3.3.2",
"react": "^16.14.0", "react": "^17.0.2",
"react-dom": "^16.14.0", "react-dom": "^17.0.2",
"react-router-dom": "^5.2.0" "react-router-dom": "^5.2.0"
}, },
"devDependencies": { "devDependencies": {
@ -35,9 +35,10 @@
"babel-loader": "^8.2.2", "babel-loader": "^8.2.2",
"clean-webpack-plugin": "^3.0.0", "clean-webpack-plugin": "^3.0.0",
"css-loader": "^5.2.4", "css-loader": "^5.2.4",
"jsdoc": "^3.6.7",
"style-loader": "^0.23.1", "style-loader": "^0.23.1",
"webpack": "^4.46.0", "webpack": "^5.38.1",
"webpack-cli": "^3.3.12", "webpack-cli": "^4.7.2",
"webpack-merge": "^4.2.2" "webpack-merge": "^4.2.2"
} }
} }

33
poetry.lock generated
View File

@ -386,6 +386,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.dependencies] [package.dependencies]
pyparsing = ">=2.0.2" pyparsing = ">=2.0.2"
[[package]]
name = "parsimonious"
version = "0.7.0"
description = "(Soon to be) the fastest pure-Python PEG parser I could muster"
category = "dev"
optional = false
python-versions = "*"
[package.dependencies]
six = "*"
[[package]] [[package]]
name = "protobuf" name = "protobuf"
version = "3.15.7" version = "3.15.7"
@ -542,6 +553,19 @@ docs = ["sphinxcontrib-websupport"]
lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.800)", "docutils-stubs"] lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.800)", "docutils-stubs"]
test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"]
[[package]]
name = "sphinx-js"
version = "3.1.2"
description = "Support for using Sphinx on JSDoc-documented JS code"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
Jinja2 = ">2.0,<3.0"
parsimonious = ">=0.7.0,<0.8.0"
Sphinx = ">=3.0.0"
[[package]] [[package]]
name = "sphinxcontrib-applehelp" name = "sphinxcontrib-applehelp"
version = "1.0.2" version = "1.0.2"
@ -707,7 +731,7 @@ python-versions = "*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.8" python-versions = "^3.8"
content-hash = "8331f6a2897615556a573dc92c00b403a8be64b3a45e4e1a6d1bfacb006c013e" content-hash = "51d1e9070adab3c839b4b1c50ebd1dc0366354d56f6c1a9485af6d4bb362959f"
[metadata.files] [metadata.files]
alabaster = [ alabaster = [
@ -951,6 +975,9 @@ packaging = [
{file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
{file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
] ]
parsimonious = [
{file = "parsimonious-0.7.0.tar.gz", hash = "sha256:396d424f64f834f9463e81ba79a331661507a21f1ed7b644f7f6a744006fd938"},
]
protobuf = [ protobuf = [
{file = "protobuf-3.15.7-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a14141d5c967362d2eedff8825d2b69cc36a5b3ed6b1f618557a04e58a3cf787"}, {file = "protobuf-3.15.7-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a14141d5c967362d2eedff8825d2b69cc36a5b3ed6b1f618557a04e58a3cf787"},
{file = "protobuf-3.15.7-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d54d78f621852ec4fdd1484d1263ca04d4bf5ffdf7abffdbb939e444b6ff3385"}, {file = "protobuf-3.15.7-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d54d78f621852ec4fdd1484d1263ca04d4bf5ffdf7abffdbb939e444b6ff3385"},
@ -1043,6 +1070,10 @@ sphinx = [
{file = "Sphinx-3.5.3-py3-none-any.whl", hash = "sha256:3f01732296465648da43dec8fb40dc451ba79eb3e2cc5c6d79005fd98197107d"}, {file = "Sphinx-3.5.3-py3-none-any.whl", hash = "sha256:3f01732296465648da43dec8fb40dc451ba79eb3e2cc5c6d79005fd98197107d"},
{file = "Sphinx-3.5.3.tar.gz", hash = "sha256:ce9c228456131bab09a3d7d10ae58474de562a6f79abb3dc811ae401cf8c1abc"}, {file = "Sphinx-3.5.3.tar.gz", hash = "sha256:ce9c228456131bab09a3d7d10ae58474de562a6f79abb3dc811ae401cf8c1abc"},
] ]
sphinx-js = [
{file = "sphinx-js-3.1.2.tar.gz", hash = "sha256:04fe0d2fec6d39b505d70500d0132cfa0efc834760c9598048c1a9dbbc175732"},
{file = "sphinx_js-3.1.2-py2.py3-none-any.whl", hash = "sha256:4503accb74ba3a15e0e59e20ec18b15be1932b2c8e8b82e03ace39a415899785"},
]
sphinxcontrib-applehelp = [ sphinxcontrib-applehelp = [
{file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"},
{file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"},

View File

@ -28,6 +28,7 @@ spotfm = { git = "https://github.com/Sarsoo/spotfm.git" }
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pylint = "^2.5.3" pylint = "^2.5.3"
Sphinx = "^3.5.3" Sphinx = "^3.5.3"
sphinx-js = "^3.1.2"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]

View File

@ -7,6 +7,9 @@ import Lock from "./Lock.js";
import Functions from "./Functions.js"; import Functions from "./Functions.js";
import Tasks from "./Tasks.js"; import Tasks from "./Tasks.js";
/**
* Admin router component for hosting cards
*/
class Admin extends Component { class Admin extends Component {
constructor(props){ constructor(props){
@ -17,6 +20,11 @@ class Admin extends Component {
this.handleChange = this.handleChange.bind(this); this.handleChange = this.handleChange.bind(this);
} }
/**
* Handle tab change event
* @param {*} e Event data
* @param {*} newValue New tab data
*/
handleChange(e, newValue){ handleChange(e, newValue){
this.setState({ this.setState({
tab: newValue tab: newValue
@ -34,8 +42,13 @@ class Admin extends Component {
centered centered
width="50%" width="50%"
> >
{/* LOCK CARD */}
<Tab label="Lock Accounts" component={Link} to={`${this.props.match.url}/lock`} /> <Tab label="Lock Accounts" component={Link} to={`${this.props.match.url}/lock`} />
{/* FUNCTIONS CARD */}
<Tab label="Functions" component={Link} to={`${this.props.match.url}/functions`} /> <Tab label="Functions" component={Link} to={`${this.props.match.url}/functions`} />
{/* RUNNING TASKS CARD */}
<Tab label="Tasks" component={Link} to={`${this.props.match.url}/tasks`} /> <Tab label="Tasks" component={Link} to={`${this.props.match.url}/tasks`} />
</Tabs> </Tabs>
</Paper> </Paper>

View File

@ -4,6 +4,9 @@ const axios = require('axios');
import showMessage from "../Toast.js" import showMessage from "../Toast.js"
import { Card, Button, ButtonGroup, CardContent, CardActions, Typography } from "@material-ui/core"; import { Card, Button, ButtonGroup, CardContent, CardActions, Typography } from "@material-ui/core";
/**
* Admin functions card component
*/
class Functions extends Component { class Functions extends Component {
constructor(props){ constructor(props){
@ -13,6 +16,10 @@ class Functions extends Component {
this.runStats = this.runStats.bind(this); this.runStats = this.runStats.bind(this);
} }
/**
* Make run all playlists request of API
* @param {*} event Event data
*/
runAllUsers(event){ runAllUsers(event){
axios.get('/api/playlist/run/users') axios.get('/api/playlist/run/users')
.then((response) => { .then((response) => {
@ -23,6 +30,10 @@ class Functions extends Component {
}); });
} }
/**
* Make run stats request of API
* @param {*} event Event data
*/
runStats(event){ runStats(event){
axios.get('/api/spotfm/playlist/refresh/users') axios.get('/api/spotfm/playlist/refresh/users')
.then((response) => { .then((response) => {
@ -38,11 +49,17 @@ class Functions extends Component {
<div style={{maxWidth: '1000px', margin: 'auto', marginTop: '20px'}}> <div style={{maxWidth: '1000px', margin: 'auto', marginTop: '20px'}}>
<Card align="center"> <Card align="center">
<CardContent> <CardContent>
{/* TITLE */}
<Typography variant="h4" color="textPrimary">Admin Functions</Typography> <Typography variant="h4" color="textPrimary">Admin Functions</Typography>
</CardContent> </CardContent>
<CardActions> <CardActions>
<ButtonGroup variant="contained" color="primary" className="full-width"> <ButtonGroup variant="contained" color="primary" className="full-width">
{/* RUN ALL PLAYLISTS BUTTON */}
<Button className="full-width button" onClick={this.runAllUsers}>Run All Users</Button> <Button className="full-width button" onClick={this.runAllUsers}>Run All Users</Button>
{/* RUN STATS BUTTON */}
<Button className="full-width button" onClick={this.runStats}>Run Stats</Button> <Button className="full-width button" onClick={this.runStats}>Run Stats</Button>
</ButtonGroup> </ButtonGroup>
</CardActions> </CardActions>

View File

@ -13,6 +13,9 @@ const useStyles = makeStyles({
}, },
}); });
/**
* Account lock card component
*/
class Lock extends Component { class Lock extends Component {
constructor(props){ constructor(props){
@ -27,6 +30,9 @@ class Lock extends Component {
this.handleLock = this.handleLock.bind(this); this.handleLock = this.handleLock.bind(this);
} }
/**
* Make user infor request of API
*/
getUserInfo(){ getUserInfo(){
axios.get('/api/users') axios.get('/api/users')
.then((response) => { .then((response) => {
@ -40,6 +46,12 @@ class Lock extends Component {
}); });
} }
/**
* Make lock request of API
* @param {*} event Event data
* @param {*} username Subject username
* @param {*} to_state Target lock state
*/
handleLock(event, username, to_state){ handleLock(event, username, to_state){
axios.post('/api/user', { axios.post('/api/user', {
username: username, username: username,
@ -60,8 +72,12 @@ class Lock extends Component {
<div style={{maxWidth: '1000px', margin: 'auto', marginTop: '20px'}}> <div style={{maxWidth: '1000px', margin: 'auto', marginTop: '20px'}}>
<Card align="center"> <Card align="center">
<CardContent> <CardContent>
{/* TITLE */}
<Typography variant="h4" color="textPrimary">Account Locks</Typography> <Typography variant="h4" color="textPrimary">Account Locks</Typography>
<Grid container spacing={3}> <Grid container spacing={3}>
{/* ACCOUNT CARDS */}
{ this.state.accounts.map((account) => <Row account={account} handler={this.handleLock} { this.state.accounts.map((account) => <Row account={account} handler={this.handleLock}
key= {account.username}/>) } key= {account.username}/>) }
</Grid> </Grid>
@ -72,16 +88,27 @@ class Lock extends Component {
} }
} }
/**
* Grid of account cards with lock buttons
* @param {*} props
* @returns
*/
function Row(props){ function Row(props){
const classes = useStyles(); const classes = useStyles();
return ( return (
<Grid item xs={12} sm={3} md={2}> <Grid item xs={12} sm={3} md={2}>
<Card variant="outlined" className={classes.root}> <Card variant="outlined" className={classes.root}>
<CardContent> <CardContent>
{/* USERNAME TITLE */}
<Typography variant="h5" color="textSecondary" className={classes.root}>{ props.account.username }</Typography> <Typography variant="h5" color="textSecondary" className={classes.root}>{ props.account.username }</Typography>
{/* LAST LOGIN */}
<Typography variant="body2" color="textSecondary" className={classes.root}>{ props.account.last_login }</Typography> <Typography variant="body2" color="textSecondary" className={classes.root}>{ props.account.last_login }</Typography>
</CardContent> </CardContent>
<CardActions> <CardActions>
{/* LOCK BUTTON */}
<Button className="full-width" color="secondary" variant="contained" aria-label="delete" onClick={(e) => props.handler(e, props.account.username, !props.account.locked)}> <Button className="full-width" color="secondary" variant="contained" aria-label="delete" onClick={(e) => props.handler(e, props.account.username, !props.account.locked)}>
{props.account.locked ? "Unlock" : "Lock"} {props.account.locked ? "Unlock" : "Lock"}
</Button> </Button>

View File

@ -5,6 +5,9 @@ import { Card, CardContent, Typography, Grid } from '@material-ui/core';
import showMessage from "../Toast.js" import showMessage from "../Toast.js"
/**
* Running tasks card component
*/
class Tasks extends Component { class Tasks extends Component {
constructor(props){ constructor(props){
@ -17,6 +20,9 @@ class Tasks extends Component {
this.getTasks(); this.getTasks();
} }
/**
* Get tasks from API
*/
getTasks(){ getTasks(){
var self = this; var self = this;
axios.get('/api/admin/tasks') axios.get('/api/admin/tasks')
@ -34,6 +40,8 @@ class Tasks extends Component {
render () { render () {
return ( return (
<div style={{maxWidth: '1000px', margin: 'auto', marginTop: '20px'}}> <div style={{maxWidth: '1000px', margin: 'auto', marginTop: '20px'}}>
{/* GRID OF TASK CARDS */}
<Grid container spacing={4}> <Grid container spacing={4}>
{ this.state.tasks.map((entry) => <TaskType url={entry.url} count={entry.count} times={entry.scheduled_times} key={entry.url}/>)} { this.state.tasks.map((entry) => <TaskType url={entry.url} count={entry.count} times={entry.scheduled_times} key={entry.url}/>)}
</Grid> </Grid>
@ -42,6 +50,11 @@ class Tasks extends Component {
} }
} }
/**
* Grid of task cards
* @param {*} props
* @returns
*/
function TaskType(props) { function TaskType(props) {
return ( return (
<Grid item xs={12} sm={6} md={4}> <Grid item xs={12} sm={6} md={4}>

View File

@ -2,6 +2,9 @@ import React, { Component } from "react";
import { Card, CardContent, Typography, Grid } from '@material-ui/core'; import { Card, CardContent, Typography, Grid } from '@material-ui/core';
/**
* Into card for the home page
*/
class Index extends Component{ class Index extends Component{
constructor(props){ constructor(props){

View File

@ -1,5 +1,7 @@
import React, { Component } from "react"; import React, { Component } from "react";
var Chart = require('chart.js'); import { Chart, BarElement, BarController, LinearScale, CategoryScale, Legend, Title, Tooltip } from 'chart.js';
Chart.register(BarElement, BarController, LinearScale, CategoryScale, Legend, Title, Tooltip);
class BarChart extends Component { class BarChart extends Component {
@ -20,30 +22,39 @@ class BarChart extends Component {
}] }]
}, },
options: { options: {
legend : { indexAxis: this.props.indexAxis, // vertical or horizontal bars
display : false plugins: {
legend : {
display : true,
labels: {
color: 'white'
}
}
}, },
elements: { elements: {
rectangle : { bar : {
backgroundColor: 'rgb(255, 255, 255)' backgroundColor: 'rgb(255, 255, 255)',
borderWidth: 2,
borderColor: 'rgb(0, 0, 0)'
} }
}, },
scales: { scales: {
yAxes: [{ y: {
ticks: { ticks: {
fontColor: "#d8d8d8", color: "#d8d8d8",
fontSize: 16, font: {
stepSize: 1, size: 20
beginAtZero: true }
} }
}], },
xAxes: [{ x: {
ticks: { ticks: {
fontColor: "#d8d8d8", color: "#d8d8d8",
fontSize: 16, font: {
stepSize: 1 size: 16
}
} }
}] }
} }
} }
}); });

View File

@ -1,5 +1,15 @@
import React, { Component } from "react"; import React, { Component } from "react";
var Chart = require('chart.js'); import { Chart, ArcElement, DoughnutController, Legend, Title, Tooltip } from 'chart.js';
Chart.register(ArcElement, DoughnutController, Legend, Title, Tooltip);
var pieColours = ['rgb(55, 61, 255)', //blue
'rgb(255, 55, 61)', //red
'rgb(7, 211, 4)', //green
'rgb(228, 242, 31)', //yellow
'rgb(31, 242, 221)', //light blue
'rgb(242, 31, 235)', //pink
'rgb(242, 164, 31)'];
class PieChart extends Component { class PieChart extends Component {
@ -20,36 +30,20 @@ class PieChart extends Component {
}] }]
}, },
options: { options: {
legend : { plugins: {
display : true, legend : {
labels: { display : true,
fontColor: 'white' labels: {
} color: 'white'
}
}
},
layout: {
padding: this.props.padding
}, },
elements: { elements: {
arc : { arc : {
backgroundColor: ['rgb(55, 61, 255)', //blue backgroundColor: [...pieColours, ...pieColours, ...pieColours],
'rgb(255, 55, 61)', //red
'rgb(7, 211, 4)', //green
'rgb(228, 242, 31)', //yellow
'rgb(31, 242, 221)', //light blue
'rgb(242, 31, 235)', //pink
'rgb(242, 164, 31)', //orange
'rgb(55, 61, 255)', //blue
'rgb(255, 55, 61)', //red
'rgb(7, 211, 4)', //green
'rgb(228, 242, 31)', //yellow
'rgb(31, 242, 221)', //light blue
'rgb(242, 31, 235)', //pink
'rgb(242, 164, 31)', //orange
'rgb(55, 61, 255)', //blue
'rgb(255, 55, 61)', //red
'rgb(7, 211, 4)', //green
'rgb(228, 242, 31)', //yellow
'rgb(31, 242, 221)', //light blue
'rgb(242, 31, 235)', //pink
'rgb(242, 164, 31)' //orange
],
borderWidth: 2, borderWidth: 2,
borderColor: 'rgb(0, 0, 0)' borderColor: 'rgb(0, 0, 0)'
} }

View File

@ -34,6 +34,9 @@ const LazyAdmin = React.lazy(() => import("./Admin/AdminRouter"))
const LazyTags = React.lazy(() => import("./Tag/TagRouter")) const LazyTags = React.lazy(() => import("./Tag/TagRouter"))
const LazyTag = React.lazy(() => import("./Tag/View")) const LazyTag = React.lazy(() => import("./Tag/View"))
/**
* Root component for app
*/
class MusicTools extends Component { class MusicTools extends Component {
constructor(props){ constructor(props){
@ -46,14 +49,23 @@ class MusicTools extends Component {
this.setOpen = this.setOpen.bind(this); this.setOpen = this.setOpen.bind(this);
} }
/**
* Get user info from API on load
*/
componentDidMount() { componentDidMount() {
this.getUserInfo(); this.getUserInfo();
} }
/**
* Cancel get user info request
*/
componentWillUnmount() { componentWillUnmount() {
this.userInfoCancelToken.cancel(); this.userInfoCancelToken.cancel();
} }
/**
* Get user info from API
*/
getUserInfo(){ getUserInfo(){
this.userInfoCancelToken = axios.CancelToken.source(); this.userInfoCancelToken = axios.CancelToken.source();
@ -72,6 +84,10 @@ class MusicTools extends Component {
}); });
} }
/**
* Set whether side app drawer is open
* @param {*} bool Open state of side drawer
*/
setOpen(bool){ setOpen(bool){
this.setState({ this.setState({
drawerOpen: bool drawerOpen: bool
@ -82,16 +98,22 @@ class MusicTools extends Component {
return ( return (
<Router> <Router>
<ThemeProvider theme={GlobalTheme}> <ThemeProvider theme={GlobalTheme}>
{/* TOP APP BAR */}
<AppBar position="static"> <AppBar position="static">
<Toolbar> <Toolbar>
<IconButton edge="start" color="inherit" aria-label="menu" onClick={(e) => this.setOpen(true)}> <IconButton edge="start" color="inherit" aria-label="menu" onClick={(e) => this.setOpen(true)}>
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
<Typography variant="h6"> <Typography variant="h6">
<Link to='/app/playlists' style={{textDecoration: 'none'}}>Music Tools</Link> <Link to='/app/playlists' style={{textDecoration: 'none'}}>Music Tools</Link>
</Typography> </Typography>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
{/* MENU DRAWER */}
<Drawer <Drawer
variant="persistent" variant="persistent"
anchor="left" anchor="left"
@ -99,9 +121,9 @@ class MusicTools extends Component {
onClose={(e) => this.setOpen(false)} onClose={(e) => this.setOpen(false)}
> >
<div> <div>
<IconButton onClick={(e) => this.setOpen(false)}> <IconButton onClick={(e) => this.setOpen(false)}>
<ChevronLeftIcon /> <ChevronLeftIcon />
</IconButton> </IconButton>
</div> </div>
<Divider /> <Divider />
<div <div
@ -110,32 +132,45 @@ class MusicTools extends Component {
onKeyDown={(e) => this.setOpen(false)} onKeyDown={(e) => this.setOpen(false)}
> >
<List> <List>
{/* HOME */}
<ListItem button key="home" component={Link} to='/app'> <ListItem button key="home" component={Link} to='/app'>
<ListItemIcon><HomeIcon /></ListItemIcon> <ListItemIcon><HomeIcon /></ListItemIcon>
<ListItemText primary="Home" /> <ListItemText primary="Home" />
</ListItem> </ListItem>
{/* PLAYLISTS */}
<ListItem button key="playlists" component={Link} to='/app/playlists'> <ListItem button key="playlists" component={Link} to='/app/playlists'>
<ListItemIcon><QueueMusic /></ListItemIcon> <ListItemIcon><QueueMusic /></ListItemIcon>
<ListItemText primary="Playlists" /> <ListItemText primary="Playlists" />
</ListItem> </ListItem>
{/* TAGS */}
<ListItem button key="tags" component={Link} to='/app/tags'> <ListItem button key="tags" component={Link} to='/app/tags'>
<ListItemIcon><GroupWork /></ListItemIcon> <ListItemIcon><GroupWork /></ListItemIcon>
<ListItemText primary="Tags" /> <ListItemText primary="Tags" />
</ListItem> </ListItem>
{/* SETTINGS */}
<ListItem button key="settings" component={Link} to='/app/settings/password'> <ListItem button key="settings" component={Link} to='/app/settings/password'>
<ListItemIcon><Build /></ListItemIcon> <ListItemIcon><Build /></ListItemIcon>
<ListItemText primary="Settings" /> <ListItemText primary="Settings" />
</ListItem> </ListItem>
{/* ADMIN */}
{ this.state.type == 'admin' && { this.state.type == 'admin' &&
<ListItem button key="admin" component={Link} to='/app/admin/lock'> <ListItem button key="admin" component={Link} to='/app/admin/lock'>
<ListItemIcon><AccountCircle /></ListItemIcon> <ListItemIcon><AccountCircle /></ListItemIcon>
<ListItemText primary="Admin" /> <ListItemText primary="Admin" />
</ListItem> </ListItem>
} }
{/* LOGOUT */}
<ListItem button key="logout" onClick={(e) => { window.location.href = '/auth/logout' }}> <ListItem button key="logout" onClick={(e) => { window.location.href = '/auth/logout' }}>
<ListItemIcon><KeyboardBackspace /></ListItemIcon> <ListItemIcon><KeyboardBackspace /></ListItemIcon>
<ListItemText primary="Logout" /> <ListItemText primary="Logout" />
</ListItem> </ListItem>
{/* SARSOO.XYZ */}
<ListItem button key="sarsoo.xyz" onClick={(e) => { window.location.href = 'https://sarsoo.xyz' }}> <ListItem button key="sarsoo.xyz" onClick={(e) => { window.location.href = 'https://sarsoo.xyz' }}>
<ListItemIcon><ExitToApp /></ListItemIcon> <ListItemIcon><ExitToApp /></ListItemIcon>
<ListItemText primary="sarsoo.xyz" /> <ListItemText primary="sarsoo.xyz" />
@ -143,6 +178,9 @@ class MusicTools extends Component {
</List> </List>
</div> </div>
</Drawer> </Drawer>
{/* ROUTER SWITCH */}
<div className="full-width"> <div className="full-width">
<Switch> <Switch>
<React.Suspense fallback={<LoadingMessage/>}> <React.Suspense fallback={<LoadingMessage/>}>

View File

@ -4,12 +4,19 @@ import { Route, Switch } from "react-router-dom";
import PlaylistsView from "./PlaylistsList.js" import PlaylistsView from "./PlaylistsList.js"
import NewPlaylist from "./New.js"; import NewPlaylist from "./New.js";
/**
* Router for playlist lists page, includes new playlist page
*/
class Playlists extends Component { class Playlists extends Component {
render(){ render(){
return ( return (
<div> <div>
<Switch> <Switch>
{/* PLAYLIST LIST */}
<Route exact path={`${this.props.match.url}/`} component={PlaylistsView} /> <Route exact path={`${this.props.match.url}/`} component={PlaylistsView} />
{/* NEW PLAYLIST */}
<Route path={`${this.props.match.url}/new`} component={NewPlaylist} /> <Route path={`${this.props.match.url}/new`} component={NewPlaylist} />
</Switch> </Switch>
</div> </div>

View File

@ -5,6 +5,9 @@ import { Card, Button, FormControl, TextField, InputLabel, Select, CardActions,
import showMessage from "../Toast.js" import showMessage from "../Toast.js"
/**
* New playlist card
*/
class NewPlaylist extends Component { class NewPlaylist extends Component {
constructor(props) { constructor(props) {
@ -18,10 +21,15 @@ class NewPlaylist extends Component {
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
} }
/** Set initial state of playlist type description */
componentDidMount(){ componentDidMount(){
this.setDescription('default'); this.setDescription('default');
} }
/**
* Set playlist type description
* @param {*} value Playlist type string to match
*/
setDescription(value){ setDescription(value){
switch(value){ switch(value){
case 'default': case 'default':
@ -42,6 +50,10 @@ class NewPlaylist extends Component {
} }
} }
/**
* Handle input changes by setting state
* @param {*} event
*/
handleInputChange(event){ handleInputChange(event){
this.setState({ this.setState({
[event.target.name]: event.target.value [event.target.name]: event.target.value
@ -49,6 +61,10 @@ class NewPlaylist extends Component {
this.setDescription(event.target.value); this.setDescription(event.target.value);
} }
/**
* Validate input and make new playlist API request
* @param {*} event
*/
handleSubmit(event){ handleSubmit(event){
var name = this.state.name; var name = this.state.name;
this.setState({ this.setState({
@ -92,9 +108,13 @@ class NewPlaylist extends Component {
<Card align="center"> <Card align="center">
<CardContent> <CardContent>
<Grid container spacing={5}> <Grid container spacing={5}>
{/* TITLE */}
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="h3">New</Typography> <Typography variant="h3">New</Typography>
</Grid> </Grid>
{/* PLAYLIST TYPE DROPDOWN */}
<Grid item xs={12} sm={4}> <Grid item xs={12} sm={4}>
<FormControl variant="filled"> <FormControl variant="filled">
<InputLabel htmlFor="type-select">Type</InputLabel> <InputLabel htmlFor="type-select">Type</InputLabel>
@ -114,6 +134,8 @@ class NewPlaylist extends Component {
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
{/* PLAYLIST NAME TEXTBOX */}
<Grid item xs={12} sm={8}> <Grid item xs={12} sm={8}>
<TextField <TextField
label="Name" label="Name"
@ -123,11 +145,15 @@ class NewPlaylist extends Component {
value={this.state.name} value={this.state.name}
className="full-width" /> className="full-width" />
</Grid> </Grid>
{/* PLAYLIST DESCRIPTION TEXT */}
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="body2" color="textSecondary">{ this.state.description }</Typography> <Typography variant="body2" color="textSecondary">{ this.state.description }</Typography>
</Grid> </Grid>
</Grid> </Grid>
</CardContent> </CardContent>
{/* SUBMIT BUTTON */}
<CardActions> <CardActions>
<Button variant="contained" color="primary" className="full-width" onClick={this.handleSubmit}>Create</Button> <Button variant="contained" color="primary" className="full-width" onClick={this.handleSubmit}>Create</Button>
</CardActions> </CardActions>

View File

@ -7,8 +7,15 @@ const axios = require('axios');
import showMessage from "../Toast.js" import showMessage from "../Toast.js"
/**
* Top-level object for hosting playlist card grid with new/run all buttons
*/
class PlaylistsView extends Component { class PlaylistsView extends Component {
/**
* Trigger loading playlist data during init
* @param {*} props Component properties
*/
constructor(props){ constructor(props){
super(props); super(props);
this.state = { this.state = {
@ -20,6 +27,9 @@ class PlaylistsView extends Component {
this.handleRunAll = this.handleRunAll.bind(this); this.handleRunAll = this.handleRunAll.bind(this);
} }
/**
* Get playlist data from API and set state with results
*/
getPlaylists(){ getPlaylists(){
var self = this; var self = this;
axios.get('/api/playlists') axios.get('/api/playlists')
@ -43,6 +53,11 @@ class PlaylistsView extends Component {
}); });
} }
/**
* Post run playlist action to API
* @param {*} name Playlist name to run
* @param {*} event Event data
*/
handleRunPlaylist(name, event){ handleRunPlaylist(name, event){
axios.get('/api/user') axios.get('/api/user')
.then((response) => { .then((response) => {
@ -62,6 +77,11 @@ class PlaylistsView extends Component {
}); });
} }
/**
* Post delete playlist action to API
* @param {*} name Playlist name to delete
* @param {*} event Event data
*/
handleDeletePlaylist(name, event){ handleDeletePlaylist(name, event){
axios.delete('/api/playlist', { params: { name: name } }) axios.delete('/api/playlist', { params: { name: name } })
.then((response) => { .then((response) => {
@ -72,6 +92,10 @@ class PlaylistsView extends Component {
}); });
} }
/**
* Post run all playlists action to API
* @param {*} event Event data
*/
handleRunAll(event){ handleRunAll(event){
axios.get('/api/user') axios.get('/api/user')
.then((response) => { .then((response) => {
@ -93,6 +117,8 @@ class PlaylistsView extends Component {
render() { render() {
// Show spinning loading circle until loaded playlist data
const grid = <PlaylistGrid playlists={this.state.playlists} const grid = <PlaylistGrid playlists={this.state.playlists}
handleRunPlaylist={this.handleRunPlaylist} handleRunPlaylist={this.handleRunPlaylist}
handleDeletePlaylist={this.handleDeletePlaylist} handleDeletePlaylist={this.handleDeletePlaylist}
@ -102,6 +128,11 @@ class PlaylistsView extends Component {
} }
} }
/**
* Playlist grid component for new/run all buttons and playlist cards
* @param {*} props Component properties
* @returns Grid component
*/
function PlaylistGrid(props){ function PlaylistGrid(props){
return ( return (
<Grid container <Grid container
@ -110,6 +141,8 @@ function PlaylistGrid(props){
justify="flex-start" justify="flex-start"
alignItems="flex-start" alignItems="flex-start"
style={{padding: '24px'}}> style={{padding: '24px'}}>
{/* BUTTON BLOCK (NEW/RUN ALL) */}
<Grid item xs={12} sm={6} md={2}> <Grid item xs={12} sm={6} md={2}>
<ButtonGroup <ButtonGroup
color="primary" color="primary"
@ -119,6 +152,8 @@ function PlaylistGrid(props){
<Button onClick={props.handleRunAll}>Run All</Button> <Button onClick={props.handleRunAll}>Run All</Button>
</ButtonGroup> </ButtonGroup>
</Grid> </Grid>
{/* PLAYLIST CARDS */}
{ props.playlists.length == 0 ? ( { props.playlists.length == 0 ? (
<Grid item xs={12} sm={6} md={3}> <Grid item xs={12} sm={6} md={3}>
<Typography variant="h5" component="h2">No Playlists</Typography> <Typography variant="h5" component="h2">No Playlists</Typography>
@ -133,21 +168,36 @@ function PlaylistGrid(props){
); );
} }
/**
* Playlist card component with view/run/delete buttons
* @param {*} props Component properties
* @returns Playlist card component
*/
function PlaylistCard(props){ function PlaylistCard(props){
return ( return (
<Grid item xs> <Grid item xs>
<Card> <Card>
{/* NAME TITLE */}
<CardContent> <CardContent>
<Typography variant="h4" component="h2"> <Typography variant="h4" component="h2">
{ props.playlist.name } { props.playlist.name }
</Typography> </Typography>
</CardContent> </CardContent>
{/* BUTTONS */}
<CardActions> <CardActions>
<ButtonGroup <ButtonGroup
color="primary" color="primary"
variant="contained"> variant="contained">
{/* VIEW */}
<Button component={Link} to={getPlaylistLink(props.playlist.name)}>View</Button> <Button component={Link} to={getPlaylistLink(props.playlist.name)}>View</Button>
{/* RUN */}
<Button onClick={(e) => props.handleRunPlaylist(props.playlist.name, e)}>Run</Button> <Button onClick={(e) => props.handleRunPlaylist(props.playlist.name, e)}>Run</Button>
{/* DELETE */}
<Button onClick={(e) => props.handleDeletePlaylist(props.playlist.name, e)}>Delete</Button> <Button onClick={(e) => props.handleDeletePlaylist(props.playlist.name, e)}>Delete</Button>
</ButtonGroup> </ButtonGroup>
</CardActions> </CardActions>
@ -156,6 +206,11 @@ function PlaylistCard(props){
); );
} }
/**
* Get URL for playlist given name
* @param {*} playlistName Subject playlist name
* @returns URL string
*/
function getPlaylistLink(playlistName){ function getPlaylistLink(playlistName){
return `/app/playlist/${playlistName}/edit`; return `/app/playlist/${playlistName}/edit`;
} }

View File

@ -7,6 +7,9 @@ import showMessage from "../../Toast.js"
const LazyPieChart = React.lazy(() => import("../../Maths/PieChart")) const LazyPieChart = React.lazy(() => import("../../Maths/PieChart"))
/**
* Playlist count tab for presenting listening stats
*/
export class Count extends Component { export class Count extends Component {
constructor(props){ constructor(props){
@ -34,6 +37,9 @@ export class Count extends Component {
this.updateStats = this.updateStats.bind(this); this.updateStats = this.updateStats.bind(this);
} }
/**
* Get playlist info with stats from API and set state if user has Last.fm username
*/
getUserInfo(){ getUserInfo(){
axios.get(`/api/playlist?name=${ this.state.name }`) axios.get(`/api/playlist?name=${ this.state.name }`)
.then((response) => { .then((response) => {
@ -51,6 +57,9 @@ export class Count extends Component {
}); });
} }
/**
* Make stats refresh request of API
*/
updateStats(){ updateStats(){
axios.get(`/api/spotfm/playlist/refresh?name=${ this.state.name }`) axios.get(`/api/spotfm/playlist/refresh?name=${ this.state.name }`)
.then((response) => { .then((response) => {
@ -71,19 +80,29 @@ export class Count extends Component {
<Card align="center"> <Card align="center">
<CardContent> <CardContent>
<Grid container> <Grid container>
{/* SCROBBLE COUNT */}
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="body2">Scrobble Count: <b>{this.state.playlist.lastfm_stat_count.toLocaleString()} / {this.state.playlist.lastfm_stat_percent}%</b></Typography> <Typography variant="body2">Scrobble Count: <b>{this.state.playlist.lastfm_stat_count.toLocaleString()} / {this.state.playlist.lastfm_stat_percent}%</b></Typography>
</Grid> </Grid>
{/* ALBUM COUNT */}
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="body2">Album Count: <b>{this.state.playlist.lastfm_stat_album_count.toLocaleString()} / {this.state.playlist.lastfm_stat_album_percent}%</b></Typography> <Typography variant="body2">Album Count: <b>{this.state.playlist.lastfm_stat_album_count.toLocaleString()} / {this.state.playlist.lastfm_stat_album_percent}%</b></Typography>
</Grid> </Grid>
{/* ARTIST COUNT */}
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="body2">Artist Count: <b>{this.state.playlist.lastfm_stat_artist_count.toLocaleString()} / {this.state.playlist.lastfm_stat_artist_percent}%</b></Typography> <Typography variant="body2">Artist Count: <b>{this.state.playlist.lastfm_stat_artist_count.toLocaleString()} / {this.state.playlist.lastfm_stat_artist_percent}%</b></Typography>
</Grid> </Grid>
{/* LAST UPDATED */}
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="body2">Last Updated <b>{this.state.playlist.lastfm_stat_last_refresh.toLocaleString()}</b></Typography> <Typography variant="body2">Last Updated <b>{this.state.playlist.lastfm_stat_last_refresh.toLocaleString()}</b></Typography>
</Grid> </Grid>
<React.Suspense fallback={<LoadingMessage/>}> <React.Suspense fallback={<LoadingMessage/>}>
{/* TRACK PIE */}
<Grid item xs={12} sm={12} md={4}> <Grid item xs={12} sm={12} md={4}>
<LazyPieChart data={[{ <LazyPieChart data={[{
"label": `${this.state.playlist.name} Tracks`, "label": `${this.state.playlist.name} Tracks`,
@ -92,8 +111,11 @@ export class Count extends Component {
"label": 'Other', "label": 'Other',
"value": 100 - this.state.playlist.lastfm_stat_percent "value": 100 - this.state.playlist.lastfm_stat_percent
}]} }]}
title={this.state.playlist.name}/> title={this.state.playlist.name}
padding={50}/>
</Grid> </Grid>
{/* ALBUM PIE */}
<Grid item xs={12} sm={12} md={4}> <Grid item xs={12} sm={12} md={4}>
<LazyPieChart data={[{ <LazyPieChart data={[{
"label": `${this.state.playlist.name} Albums`, "label": `${this.state.playlist.name} Albums`,
@ -102,8 +124,11 @@ export class Count extends Component {
"label": 'Other', "label": 'Other',
"value": 100 - this.state.playlist.lastfm_stat_album_percent "value": 100 - this.state.playlist.lastfm_stat_album_percent
}]} }]}
title={this.state.playlist.name}/> title={this.state.playlist.name}
padding={50}/>
</Grid> </Grid>
{/* ARTIST PIE */}
<Grid item xs={12} sm={12} md={4}> <Grid item xs={12} sm={12} md={4}>
<LazyPieChart data={[{ <LazyPieChart data={[{
"label": `${this.state.playlist.name} Artists`, "label": `${this.state.playlist.name} Artists`,
@ -112,11 +137,14 @@ export class Count extends Component {
"label": 'Other', "label": 'Other',
"value": 100 - this.state.playlist.lastfm_stat_artist_percent "value": 100 - this.state.playlist.lastfm_stat_artist_percent
}]} }]}
title={this.state.playlist.name}/> title={this.state.playlist.name}
padding={50}/>
</Grid> </Grid>
</React.Suspense> </React.Suspense>
</Grid> </Grid>
</CardContent> </CardContent>
{/* UPDATE BUTTON */}
<CardActions> <CardActions>
<Button variant="contained" color="primary" className="full-width" onClick={this.updateStats}>Update</Button> <Button variant="contained" color="primary" className="full-width" onClick={this.updateStats}>Update</Button>
</CardActions> </CardActions>

View File

@ -46,6 +46,9 @@ const useStyles = makeStyles({
}, },
}); });
/**
* Main view/edit card for playlists
*/
export class Edit extends Component{ export class Edit extends Component{
constructor(props){ constructor(props){
@ -84,6 +87,9 @@ export class Edit extends Component{
this.makeNetworkUpdate = this.makeNetworkUpdate.bind(this); this.makeNetworkUpdate = this.makeNetworkUpdate.bind(this);
} }
/**
* Get playlist info and all playlists from API, sort and set state
*/
componentDidMount(){ componentDidMount(){
axios.all([this.getPlaylistInfo(), this.getPlaylists()]) axios.all([this.getPlaylistInfo(), this.getPlaylists()])
.then(axios.spread((info, playlists) => { .then(axios.spread((info, playlists) => {
@ -120,14 +126,26 @@ export class Edit extends Component{
}); });
} }
/**
* Get API playlist info request
* @returns Playlist info request
*/
getPlaylistInfo(){ getPlaylistInfo(){
return axios.get(`/api/playlist?name=${ this.state.name }`); return axios.get(`/api/playlist?name=${ this.state.name }`);
} }
/**
* Get API list of playlist infos request
* @returns Playlists info request
*/
getPlaylists(){ getPlaylists(){
return axios.get(`/api/playlists`); return axios.get(`/api/playlists`);
} }
/**
* Handle input box state changes, make API requests
* @param {*} event
*/
handleInputChange(event){ handleInputChange(event){
this.setState({ this.setState({
@ -164,6 +182,10 @@ export class Edit extends Component{
} }
} }
/**
* Handle checkbox state changes, make API requests
* @param {*} event Event data
*/
handleCheckChange(event){ handleCheckChange(event){
this.setState({ this.setState({
@ -176,6 +198,10 @@ export class Edit extends Component{
} }
} }
/**
* Send playlist info updates to API
* @param {*} changes Dictionary of changes to make
*/
makeNetworkUpdate(changes){ makeNetworkUpdate(changes){
let payload = { let payload = {
name: this.state.name name: this.state.name
@ -190,6 +216,10 @@ export class Edit extends Component{
}); });
} }
/**
* Handle adding new watched Spotify playlist name string
* @param {*} event Event data
*/
handleAddPart(event){ handleAddPart(event){
if(this.state.newPlaylistName.length != 0){ if(this.state.newPlaylistName.length != 0){
@ -221,6 +251,10 @@ export class Edit extends Component{
} }
} }
/**
* Handle adding new watched music tools playlist reference
* @param {*} event Event data
*/
handleAddReference(event){ handleAddReference(event){
if(this.state.newReferenceName.length != 0){ if(this.state.newReferenceName.length != 0){
@ -255,6 +289,11 @@ export class Edit extends Component{
} }
} }
/**
* Handle removing watched Spotify playlist name string
* @param {*} id Subject playlist name
* @param {*} event Event data
*/
handleRemovePart(id, event){ handleRemovePart(id, event){
var parts = this.state.parts; var parts = this.state.parts;
parts = parts.filter(e => e !== id); parts = parts.filter(e => e !== id);
@ -269,6 +308,11 @@ export class Edit extends Component{
this.makeNetworkUpdate({parts: parts}); this.makeNetworkUpdate({parts: parts});
} }
/**
* Handle removing watched music tools playlist reference
* @param {*} id Subject playlist name
* @param {*} event Event data
*/
handleRemoveReference(id, event){ handleRemoveReference(id, event){
var playlist_references = this.state.playlist_references; var playlist_references = this.state.playlist_references;
playlist_references = playlist_references.filter(e => e !== id); playlist_references = playlist_references.filter(e => e !== id);
@ -283,6 +327,10 @@ export class Edit extends Component{
this.makeNetworkUpdate({playlist_references: playlist_references}); this.makeNetworkUpdate({playlist_references: playlist_references});
} }
/**
* Handle refreshing playlist action, checks for spotify link
* @param {*} event Event data
*/
handleRun(event){ handleRun(event){
axios.get('/api/user') axios.get('/api/user')
.then((response) => { .then((response) => {
@ -305,21 +353,29 @@ export class Edit extends Component{
render(){ render(){
var date = new Date(); var date = new Date();
console.log("hello from edit");
const table = ( const table = (
<div style={{maxWidth: '1000px', margin: 'auto', marginTop: '20px'}}> <div style={{maxWidth: '1000px', margin: 'auto', marginTop: '20px'}}>
<Card align="center"> <Card align="center">
<CardContent> <CardContent>
{/* PLAYLIST NAME TITLE */}
<Typography variant="h2" color="textPrimary">{this.state.name}</Typography> <Typography variant="h2" color="textPrimary">{this.state.name}</Typography>
<Grid container spacing={5}> <Grid container spacing={5}>
{/* MANAGED PLAYLISTS TITLE */}
{ this.state.playlist_references.length > 0 && <Grid item xs={12} ><Typography color="textSecondary" variant="h4">Managed</Typography></Grid> } { this.state.playlist_references.length > 0 && <Grid item xs={12} ><Typography color="textSecondary" variant="h4">Managed</Typography></Grid> }
{/* SMART PLAYLIST REFERENCES */}
{ this.state.playlist_references.length > 0 && <ListBlock handler={this.handleRemoveReference} list={this.state.playlist_references}/> } { this.state.playlist_references.length > 0 && <ListBlock handler={this.handleRemoveReference} list={this.state.playlist_references}/> }
{/* SPOTIFY PLALISTS TITLE */}
{ this.state.parts.length > 0 && <Grid item xs={12} ><Typography color="textSecondary" variant="h4">Spotify</Typography></Grid> } { this.state.parts.length > 0 && <Grid item xs={12} ><Typography color="textSecondary" variant="h4">Spotify</Typography></Grid> }
{/* SPOTIFY PLAYLIST REFERENCES */}
{ this.state.parts.length > 0 && <ListBlock handler={this.handleRemovePart} list={this.state.parts}/> } { this.state.parts.length > 0 && <ListBlock handler={this.handleRemovePart} list={this.state.parts}/> }
{/* SPOTIFY DESCRIPTION */}
<Grid item xs={12} ><Typography variant="body2" color="textSecondary">Spotify playlist can be the name of either your own created playlist or one you follow, names are case sensitive</Typography></Grid> <Grid item xs={12} ><Typography variant="body2" color="textSecondary">Spotify playlist can be the name of either your own created playlist or one you follow, names are case sensitive</Typography></Grid>
{/* SPOTIFY PLAYLIST TEXTBOX */}
<Grid item xs={8} sm={8} md={3}> <Grid item xs={8} sm={8} md={3}>
<TextField <TextField
name="newPlaylistName" name="newPlaylistName"
@ -330,9 +386,12 @@ export class Edit extends Component{
/> />
</Grid> </Grid>
{/* SPOTIFY ADD BUTTON */}
<Grid item xs={4} sm={4} md={3}> <Grid item xs={4} sm={4} md={3}>
<Button variant="contained" className="full-width" onClick={this.handleAddPart} style={{verticalAlign: 'middle'}}>Add</Button> <Button variant="contained" className="full-width" onClick={this.handleAddPart} style={{verticalAlign: 'middle'}}>Add</Button>
</Grid> </Grid>
{/* SMART PLAYLIST DROPDOWN */}
<Grid item xs={8} sm={8} md={3}> <Grid item xs={8} sm={8} md={3}>
<FormControl variant="filled" style={{verticalAlign: 'middle'}}> <FormControl variant="filled" style={{verticalAlign: 'middle'}}>
<InputLabel htmlFor="chart_range">Managed Playlist</InputLabel> <InputLabel htmlFor="chart_range">Managed Playlist</InputLabel>
@ -351,22 +410,31 @@ export class Edit extends Component{
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
{/* SMART ADD BUTTON */}
<Grid item xs={4} sm={4} md={3}> <Grid item xs={4} sm={4} md={3}>
<Button variant="contained" className="full-width" onClick={this.handleAddReference} style={{verticalAlign: 'middle'}}>Add</Button> <Button variant="contained" className="full-width" onClick={this.handleAddReference} style={{verticalAlign: 'middle'}}>Add</Button>
</Grid> </Grid>
{/* CHECKBOXES */}
<Grid item xs={12}> <Grid item xs={12}>
{/* SHUFFLE */}
<FormControlLabel <FormControlLabel
control={ control={
<Switch color="primary" name="shuffle" checked={this.state.shuffle} onChange={this.handleCheckChange} /> <Switch color="primary" name="shuffle" checked={this.state.shuffle} onChange={this.handleCheckChange} />
} }
labelPlacement="bottom" labelPlacement="bottom"
label="Shuffle"/> label="Shuffle"/>
{/* RECOMMENDATIONS */}
<FormControlLabel <FormControlLabel
control={ control={
<Switch color="primary" checked={this.state.include_recommendations} name="include_recommendations" onChange={this.handleCheckChange} /> <Switch color="primary" checked={this.state.include_recommendations} name="include_recommendations" onChange={this.handleCheckChange} />
} }
labelPlacement="bottom" labelPlacement="bottom"
label="Recommendations"/> label="Recommendations"/>
{/* LIBRARY TRACKS */}
<FormControlLabel <FormControlLabel
control={ control={
<Switch color="primary" checked={this.state.include_library_tracks} name="include_library_tracks" onChange={this.handleCheckChange} /> <Switch color="primary" checked={this.state.include_library_tracks} name="include_library_tracks" onChange={this.handleCheckChange} />
@ -374,6 +442,8 @@ export class Edit extends Component{
labelPlacement="bottom" labelPlacement="bottom"
label="Library Tracks"/> label="Library Tracks"/>
</Grid> </Grid>
{/* NUMBER OF RECOMMENDATIONS */}
{ this.state.include_recommendations == true && { this.state.include_recommendations == true &&
<Grid item xs={12}> <Grid item xs={12}>
<TextField type="number" <TextField type="number"
@ -384,6 +454,8 @@ export class Edit extends Component{
onChange={this.handleInputChange}></TextField> onChange={this.handleInputChange}></TextField>
</Grid> </Grid>
} }
{/* LAST.FM CHART LENGTH */}
{ this.state.type == 'fmchart' && { this.state.type == 'fmchart' &&
<Grid item xs={12}> <Grid item xs={12}>
<TextField type="number" <TextField type="number"
@ -394,6 +466,8 @@ export class Edit extends Component{
onChange={this.handleInputChange}></TextField> onChange={this.handleInputChange}></TextField>
</Grid> </Grid>
} }
{/* LAST.FM CHART TIME RANGE */}
{ this.state.type == 'fmchart' && { this.state.type == 'fmchart' &&
<Grid item xs={12}> <Grid item xs={12}>
<FormControl variant="filled"> <FormControl variant="filled">
@ -416,6 +490,8 @@ export class Edit extends Component{
</FormControl> </FormControl>
</Grid> </Grid>
} }
{/* RECENTS DAYS SINCE */}
{ this.state.type == 'recents' && { this.state.type == 'recents' &&
<Grid item xs={12}> <Grid item xs={12}>
<TextField type="number" <TextField type="number"
@ -426,7 +502,11 @@ export class Edit extends Component{
onChange={this.handleInputChange} /> onChange={this.handleInputChange} />
</Grid> </Grid>
} }
{/* THIS/LAST MONTH */}
<Grid item xs={12}> <Grid item xs={12}>
{/* THIS MONTH */}
<FormControlLabel <FormControlLabel
control={ control={
<Switch color="primary" checked={this.state.add_this_month} name="add_this_month" onChange={this.handleCheckChange} /> <Switch color="primary" checked={this.state.add_this_month} name="add_this_month" onChange={this.handleCheckChange} />
@ -434,6 +514,8 @@ export class Edit extends Component{
label="This Month" label="This Month"
labelPlacement="bottom" labelPlacement="bottom"
/> />
{/* LAST MONTH */}
<FormControlLabel <FormControlLabel
control={ control={
<Switch color="primary" checked={this.state.add_last_month} name="add_last_month" onChange={this.handleCheckChange} /> <Switch color="primary" checked={this.state.add_last_month} name="add_last_month" onChange={this.handleCheckChange} />
@ -442,6 +524,8 @@ export class Edit extends Component{
labelPlacement="bottom" labelPlacement="bottom"
/> />
</Grid> </Grid>
{/* PLAYLIST TYPE */}
<Grid item xs={12}> <Grid item xs={12}>
<FormControl variant="filled"> <FormControl variant="filled">
<InputLabel htmlFor="type-select">Type</InputLabel> <InputLabel htmlFor="type-select">Type</InputLabel>
@ -461,6 +545,8 @@ export class Edit extends Component{
</Grid> </Grid>
</Grid> </Grid>
</CardContent> </CardContent>
{/* RUN PLAYLIST */}
<CardActions> <CardActions>
<Button onClick={this.handleRun} variant="contained" color="primary" className="full-width" >Run</Button> <Button onClick={this.handleRun} variant="contained" color="primary" className="full-width" >Run</Button>
</CardActions> </CardActions>
@ -473,11 +559,21 @@ export class Edit extends Component{
} }
/**
* Smart playlist entry in dropbox
* @param {*} props Properties containing name
* @returns Dropbox option component
*/
function ReferenceEntry(props) { function ReferenceEntry(props) {
return <option value={props.name}>{props.name}</option>; return <option value={props.name}>{props.name}</option>;
// return <MenuItem value={props.name}>{props.name}</MenuItem>; // return <MenuItem value={props.name}>{props.name}</MenuItem>;
} }
/**
* Grid of cards for smart/Spotify playlist names with delete button
* @param {*} props Properties
* @returns Grid component
*/
function ListBlock(props) { function ListBlock(props) {
return <Grid container return <Grid container
spacing={3} spacing={3}
@ -489,6 +585,11 @@ function ListBlock(props) {
</Grid> </Grid>
} }
/**
* Smart/Spotify playlist card including name and delete button
* @param {*} props Properties
* @returns Card component wrapped in grid cell
*/
function BlockGridItem (props) { function BlockGridItem (props) {
const classes = useStyles(); const classes = useStyles();
return ( return (

View File

@ -6,6 +6,9 @@ import { Paper, Tabs, Tab} from '@material-ui/core';
import {Edit} from "./Edit.js"; import {Edit} from "./Edit.js";
import {Count} from "./Count.js"; import {Count} from "./Count.js";
/**
* Playlist view structure with tabs for view/editing and statistics
*/
class View extends Component{ class View extends Component{
constructor(props){ constructor(props){
@ -16,6 +19,11 @@ class View extends Component{
this.handleChange = this.handleChange.bind(this); this.handleChange = this.handleChange.bind(this);
} }
/**
* Handle tab change event
* @param {*} e Event args
* @param {*} newValue New tab object
*/
handleChange(e, newValue){ handleChange(e, newValue){
this.setState({ this.setState({
tab: newValue tab: newValue
@ -33,12 +41,20 @@ class View extends Component{
centered centered
width="50%" width="50%"
> >
{/* VIEW/EDIT */}
<Tab label="Edit" component={Link} to={`${this.props.match.url}/edit`} /> <Tab label="Edit" component={Link} to={`${this.props.match.url}/edit`} />
{/* STATS */}
<Tab label="Count" component={Link} to={`${this.props.match.url}/count`} /> <Tab label="Count" component={Link} to={`${this.props.match.url}/count`} />
</Tabs> </Tabs>
</Paper> </Paper>
<Switch> <Switch>
{/* VIEW/EDIT */}
<Route path={`${this.props.match.url}/edit`} render={(props) => <Edit {...props} name={this.props.match.params.name}/>} /> <Route path={`${this.props.match.url}/edit`} render={(props) => <Edit {...props} name={this.props.match.params.name}/>} />
{/* STATS */}
<Route path={`${this.props.match.url}/count`} render={(props) => <Count {...props} name={this.props.match.params.name}/>} /> <Route path={`${this.props.match.url}/count`} render={(props) => <Count {...props} name={this.props.match.params.name}/>} />
</Switch> </Switch>
</div> </div>

View File

@ -5,6 +5,9 @@ import { Card, Grid, Button, TextField, CardContent, CardActions, Typography } f
import showMessage from "../Toast.js" import showMessage from "../Toast.js"
/**
* Change password card
*/
class ChangePassword extends Component { class ChangePassword extends Component {
constructor(props){ constructor(props){
@ -20,22 +23,40 @@ class ChangePassword extends Component {
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
} }
/**
* Handle current pw state change
* @param {*} event Event data
*/
handleCurrentChange(event){ handleCurrentChange(event){
this.setState({ this.setState({
'current': event.target.value 'current': event.target.value
}); });
} }
/**
* Handle new pw state change
* @param {*} event Event data
*/
handleNewChange(event){ handleNewChange(event){
this.setState({ this.setState({
'new1': event.target.value 'new1': event.target.value
}); });
} }
/**
* Handle new again pw state change
* @param {*} event Event data
*/
handleNew2Change(event){ handleNew2Change(event){
this.setState({ this.setState({
'new2': event.target.value 'new2': event.target.value
}); });
} }
/**
* Handle submit button click, validate input, make network request
* @param {*} event Event data
*/
handleSubmit(event){ handleSubmit(event){
if(this.state.current.length == 0){ if(this.state.current.length == 0){
@ -70,9 +91,13 @@ class ChangePassword extends Component {
<form onSubmit={this.handleSubmit}> <form onSubmit={this.handleSubmit}>
<CardContent> <CardContent>
<Grid container spacing={2}> <Grid container spacing={2}>
{/* TITLE */}
<Grid item className="full-width"> <Grid item className="full-width">
<Typography variant="h4" color="textPrimary">Change Password</Typography> <Typography variant="h4" color="textPrimary">Change Password</Typography>
</Grid> </Grid>
{/* CURRENT PASSWORD */}
<Grid item className="full-width"> <Grid item className="full-width">
<TextField <TextField
label="Current Password" label="Current Password"
@ -83,6 +108,8 @@ class ChangePassword extends Component {
value={this.state.current} value={this.state.current}
className="full-width" /> className="full-width" />
</Grid> </Grid>
{/* NEW PASSWORD */}
<Grid item className="full-width"> <Grid item className="full-width">
<TextField <TextField
label="New Password" label="New Password"
@ -93,6 +120,8 @@ class ChangePassword extends Component {
value={this.state.new1} value={this.state.new1}
className="full-width" /> className="full-width" />
</Grid> </Grid>
{/* NEW PASSWORD 2 */}
<Grid item className="full-width"> <Grid item className="full-width">
<TextField <TextField
label="New Password Again" label="New Password Again"
@ -103,9 +132,13 @@ class ChangePassword extends Component {
value={this.state.new2} value={this.state.new2}
className="full-width" /> className="full-width" />
</Grid> </Grid>
{/* ERROR MESSAGE */}
{ this.state.error && <Grid item><Typography variant="textSeondary">{this.state.errorValue}</Typography></Grid>} { this.state.error && <Grid item><Typography variant="textSeondary">{this.state.errorValue}</Typography></Grid>}
</Grid> </Grid>
</CardContent> </CardContent>
{/* SUBMIT BUTTON */}
<CardActions> <CardActions>
<Button type="submit" variant="contained" className="full-width" onClick={this.runStats}>Change</Button> <Button type="submit" variant="contained" className="full-width" onClick={this.runStats}>Change</Button>
</CardActions> </CardActions>

View File

@ -5,6 +5,9 @@ import { Card, Button, CardContent, CardActions, Typography, TextField, Grid } f
import showMessage from "../Toast.js" import showMessage from "../Toast.js"
/**
* Last.fm username setting card
*/
class LastFM extends Component { class LastFM extends Component {
constructor(props){ constructor(props){
@ -19,6 +22,9 @@ class LastFM extends Component {
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
} }
/**
* Get user info from API, set current username to state
*/
getUserInfo(){ getUserInfo(){
axios.get('/api/user') axios.get('/api/user')
.then((response) => { .then((response) => {
@ -39,12 +45,20 @@ class LastFM extends Component {
}); });
} }
/**
* Handle input box state change
* @param {*} event Event data
*/
handleChange(event){ handleChange(event){
this.setState({ this.setState({
'lastfm_username': event.target.value 'lastfm_username': event.target.value
}); });
} }
/**
* Handle submit button, post API change request
* @param {*} event Event data
*/
handleSubmit(event){ handleSubmit(event){
var username = this.state.lastfm_username; var username = this.state.lastfm_username;
@ -72,9 +86,13 @@ class LastFM extends Component {
<form onSubmit={this.handleSubmit}> <form onSubmit={this.handleSubmit}>
<CardContent> <CardContent>
<Grid container spacing={3}> <Grid container spacing={3}>
{/* TITLE */}
<Grid item className="full-width"> <Grid item className="full-width">
<Typography variant="h4" color="textPrimary">Last.fm Username</Typography> <Typography variant="h4" color="textPrimary">Last.fm Username</Typography>
</Grid> </Grid>
{/* USERNAME TEXTBOX */}
<Grid item className="full-width"> <Grid item className="full-width">
<TextField <TextField
label="last.fm Username" label="last.fm Username"
@ -86,6 +104,8 @@ class LastFM extends Component {
</Grid> </Grid>
</Grid> </Grid>
</CardContent> </CardContent>
{/* SUBMIT BUTTON */}
<CardActions> <CardActions>
<Button type="submit" variant="contained" className="full-width">Save</Button> <Button type="submit" variant="contained" className="full-width">Save</Button>
</CardActions> </CardActions>

View File

@ -6,6 +6,9 @@ import ChangePassword from "./ChangePassword.js";
import SpotifyLink from "./SpotifyLink.js"; import SpotifyLink from "./SpotifyLink.js";
import LastFM from "./LastFM.js"; import LastFM from "./LastFM.js";
/**
* Settings card tabs structure for hosting password/spotify linked/last.fm username tabs
*/
class Settings extends Component { class Settings extends Component {
constructor(props){ constructor(props){
@ -16,6 +19,11 @@ class Settings extends Component {
this.handleChange = this.handleChange.bind(this); this.handleChange = this.handleChange.bind(this);
} }
/**
* Handle tab change event
* @param {*} e Event args
* @param {*} newValue New tab object
*/
handleChange(e, newValue){ handleChange(e, newValue){
this.setState({ this.setState({
tab: newValue tab: newValue
@ -33,14 +41,25 @@ class Settings extends Component {
centered centered
width="50%" width="50%"
> >
{/* PASSWORD */}
<Tab label="Password" component={Link} to={`${this.props.match.url}/password`} /> <Tab label="Password" component={Link} to={`${this.props.match.url}/password`} />
{/* SPOTIFY */}
<Tab label="Spotify" component={Link} to={`${this.props.match.url}/spotify`} /> <Tab label="Spotify" component={Link} to={`${this.props.match.url}/spotify`} />
{/* LAST.FM */}
<Tab label="Last.fm" component={Link} to={`${this.props.match.url}/lastfm`} /> <Tab label="Last.fm" component={Link} to={`${this.props.match.url}/lastfm`} />
</Tabs> </Tabs>
</Paper> </Paper>
<Switch> <Switch>
{/* PASSWORD */}
<Route path={`${this.props.match.url}/password`} component={ChangePassword} /> <Route path={`${this.props.match.url}/password`} component={ChangePassword} />
{/* SPOTIFY */}
<Route path={`${this.props.match.url}/spotify`} component={SpotifyLink} /> <Route path={`${this.props.match.url}/spotify`} component={SpotifyLink} />
{/* LAST.FM */}
<Route path={`${this.props.match.url}/lastfm`} component={LastFM} /> <Route path={`${this.props.match.url}/lastfm`} component={LastFM} />
</Switch> </Switch>
</div> </div>

View File

@ -5,6 +5,9 @@ import { Card, Button, CardContent, CardActions, Typography } from "@material-ui
import showMessage from "../Toast.js" import showMessage from "../Toast.js"
/**
* Spotify account link settings card
*/
class SpotifyLink extends Component { class SpotifyLink extends Component {
constructor(props){ constructor(props){
@ -16,6 +19,9 @@ class SpotifyLink extends Component {
this.getUserInfo(); this.getUserInfo();
} }
/**
* Get user info from API and set spotify link status to state
*/
getUserInfo(){ getUserInfo(){
axios.get('/api/user') axios.get('/api/user')
.then((response) => { .then((response) => {
@ -33,10 +39,14 @@ class SpotifyLink extends Component {
const table = const table =
<div style={{maxWidth: '400px', margin: 'auto', marginTop: '20px'}}> <div style={{maxWidth: '400px', margin: 'auto', marginTop: '20px'}}>
<Card align="center"> <Card align="center">
{/* STATUS */}
<CardContent> <CardContent>
<Typography variant="h4" color="textPrimary">Admin Functions</Typography> <Typography variant="h4" color="textPrimary">Admin Functions</Typography>
<Typography variant="body2" color="textSecondary">Status: { this.state.spotify_linked ? "Linked" : "Unlinked" }</Typography> <Typography variant="body2" color="textSecondary">Status: { this.state.spotify_linked ? "Linked" : "Unlinked" }</Typography>
</CardContent> </CardContent>
{/* STATE CHANGE BUTTON */}
<CardActions> <CardActions>
{ this.state.spotify_linked ? <DeAuthButton /> : <AuthButton /> } { this.state.spotify_linked ? <DeAuthButton /> : <AuthButton /> }
</CardActions> </CardActions>
@ -49,10 +59,20 @@ class SpotifyLink extends Component {
} }
} }
/**
* Authenticate Spotify account button component
* @param {*} props Properties
* @returns Button component
*/
function AuthButton(props) { function AuthButton(props) {
return <Button component='a' variant="contained" className="full-width" href="/auth/spotify">Auth</Button>; return <Button component='a' variant="contained" className="full-width" href="/auth/spotify">Auth</Button>;
} }
/**
* Deauthenticate Spotify account button component
* @param {*} props Properties
* @returns Button component
*/
function DeAuthButton(props) { function DeAuthButton(props) {
return <Button component='a' variant="contained" className="full-width" href="/auth/spotify/deauth">De-Auth</Button>; return <Button component='a' variant="contained" className="full-width" href="/auth/spotify/deauth">De-Auth</Button>;
} }

View File

@ -5,6 +5,9 @@ import { Card, Button, TextField, CardActions, CardContent, Typography, Grid } f
import showMessage from "../Toast.js" import showMessage from "../Toast.js"
/**
* New tag card component
*/
class NewTag extends Component { class NewTag extends Component {
constructor(props) { constructor(props) {
@ -16,12 +19,20 @@ class NewTag extends Component {
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
} }
/**
* Handle tag id input box state changes
* @param {*} event
*/
handleInputChange(event){ handleInputChange(event){
this.setState({ this.setState({
tag_id: event.target.value tag_id: event.target.value
}); });
} }
/**
* Validate input, make new tag API request
* @param {*} event
*/
handleSubmit(event){ handleSubmit(event){
var tag_id = this.state.tag_id; var tag_id = this.state.tag_id;
this.setState({ this.setState({
@ -58,9 +69,13 @@ class NewTag extends Component {
<Card align="center"> <Card align="center">
<CardContent> <CardContent>
<Grid container spacing={5}> <Grid container spacing={5}>
{/* TITLE */}
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="h3">New Tag</Typography> <Typography variant="h3">New Tag</Typography>
</Grid> </Grid>
{/* NAME TEXTBOX */}
<Grid item xs={12}> <Grid item xs={12}>
<TextField <TextField
label="Name" label="Name"
@ -72,6 +87,8 @@ class NewTag extends Component {
</Grid> </Grid>
</Grid> </Grid>
</CardContent> </CardContent>
{/* SUBMIT BUTTON */}
<CardActions> <CardActions>
<Button variant="contained" color="primary" className="full-width" onClick={this.handleSubmit}>Create</Button> <Button variant="contained" color="primary" className="full-width" onClick={this.handleSubmit}>Create</Button>
</CardActions> </CardActions>

View File

@ -7,6 +7,9 @@ const axios = require('axios');
import showMessage from "../Toast.js" import showMessage from "../Toast.js"
/**
* Tag card list component
*/
class TagList extends Component { class TagList extends Component {
constructor(props){ constructor(props){
@ -18,6 +21,9 @@ class TagList extends Component {
this.handleDeleteTag = this.handleDeleteTag.bind(this); this.handleDeleteTag = this.handleDeleteTag.bind(this);
} }
/**
* Get tags info from API
*/
getTags(){ getTags(){
var self = this; var self = this;
axios.get('/api/tag') axios.get('/api/tag')
@ -41,6 +47,11 @@ class TagList extends Component {
}); });
} }
/**
* Make delete tag request of API
* @param {*} tag_id Tag ID
* @param {*} event Event Data
*/
handleDeleteTag(tag_id, event){ handleDeleteTag(tag_id, event){
axios.delete(`/api/tag/${tag_id}`) axios.delete(`/api/tag/${tag_id}`)
.then((response) => { .then((response) => {
@ -60,6 +71,11 @@ class TagList extends Component {
} }
} }
/**
* Tag card grid component
* @param {*} props Properties
* @returns Grid component
*/
function TagGrid(props){ function TagGrid(props){
return ( return (
<Grid container <Grid container
@ -69,6 +85,8 @@ function TagGrid(props){
alignItems="flex-start" alignItems="flex-start"
style={{padding: '24px'}}> style={{padding: '24px'}}>
<Grid item xs={12} sm={6} md={2}> <Grid item xs={12} sm={6} md={2}>
{/* NEW BUTTON */}
<ButtonGroup <ButtonGroup
color="primary" color="primary"
orientation="vertical" orientation="vertical"
@ -76,6 +94,8 @@ function TagGrid(props){
<Button component={Link} to='tags/new' >New</Button> <Button component={Link} to='tags/new' >New</Button>
</ButtonGroup> </ButtonGroup>
</Grid> </Grid>
{/* TAG CARDS */}
{ props.tags.length == 0 ? ( { props.tags.length == 0 ? (
<Grid item xs={12} sm={6} md={3}> <Grid item xs={12} sm={6} md={3}>
<Typography variant="h5" component="h2">No Tags</Typography> <Typography variant="h5" component="h2">No Tags</Typography>
@ -90,14 +110,23 @@ function TagGrid(props){
); );
} }
/**
* Tag card component
* @param {*} props Properties
* @returns Card component
*/
function TagCard(props){ function TagCard(props){
return ( return (
<Grid item xs> <Grid item xs>
<Card> <Card>
<CardContent> <CardContent>
{/* TAG NAME */}
<Typography variant="h4" component="h2"> <Typography variant="h4" component="h2">
{ props.tag.name } { props.tag.name }
</Typography> </Typography>
{/* COUNT */}
{'count' in props.tag && {'count' in props.tag &&
<Typography variant="h6" style={{color: "#b3b3b3"}}> <Typography variant="h6" style={{color: "#b3b3b3"}}>
{ props.tag.count } { props.tag.count }
@ -108,7 +137,11 @@ function TagCard(props){
<ButtonGroup <ButtonGroup
color="primary" color="primary"
variant="contained"> variant="contained">
{/* VIEW BUTTON */}
<Button component={Link} to={getTagLink(props.tag.tag_id)}>View</Button> <Button component={Link} to={getTagLink(props.tag.tag_id)}>View</Button>
{/* DELETE BUTTON */}
<Button onClick={(e) => props.handleDeleteTag(props.tag.tag_id, e)}>Delete</Button> <Button onClick={(e) => props.handleDeleteTag(props.tag.tag_id, e)}>Delete</Button>
</ButtonGroup> </ButtonGroup>
</CardActions> </CardActions>
@ -117,6 +150,11 @@ function TagCard(props){
); );
} }
/**
* Map tag name to URL
* @param {*} tagName Subject tag name
* @returns Tag URL
*/
function getTagLink(tagName){ function getTagLink(tagName){
return `/app/tag/${tagName}`; return `/app/tag/${tagName}`;
} }

View File

@ -4,12 +4,18 @@ import { Route, Switch } from "react-router-dom";
import TagList from "./TagList.js" import TagList from "./TagList.js"
import New from "./New.js" import New from "./New.js"
/**
* Tag router for directing between tag list and new
*/
class TagRouter extends Component { class TagRouter extends Component {
render(){ render(){
return ( return (
<div> <div>
<Switch> <Switch>
{/* TAG LIST */}
<Route exact path={`${this.props.match.url}/`} component={TagList} /> <Route exact path={`${this.props.match.url}/`} component={TagList} />
{/* NEW */}
<Route path={`${this.props.match.url}/new`} component={New} /> <Route path={`${this.props.match.url}/new`} component={New} />
</Switch> </Switch>
</div> </div>

View File

@ -17,6 +17,9 @@ const useStyles = makeStyles({
}, },
}); });
/**
* Tag View card
*/
class View extends Component{ class View extends Component{
constructor(props){ constructor(props){
@ -48,6 +51,9 @@ class View extends Component{
this.handleChangeAddType = this.handleChangeAddType.bind(this); this.handleChangeAddType = this.handleChangeAddType.bind(this);
} }
/**
* Get tag info from API on load
*/
componentDidMount(){ componentDidMount(){
this.getTag(); this.getTag();
// var intervalId = setInterval(() => {this.getTag(false)}, 5000); // var intervalId = setInterval(() => {this.getTag(false)}, 5000);
@ -64,6 +70,10 @@ class View extends Component{
// clearTimeout(this.state.timeoutId); // clearTimeout(this.state.timeoutId);
// } // }
/**
* Get tag info from API
* @param {*} error_toast Whether to show toast on network error
*/
getTag(error_toast = true){ getTag(error_toast = true){
axios.get(`/api/tag/${ this.state.tag_id }`) axios.get(`/api/tag/${ this.state.tag_id }`)
.then( (response) => { .then( (response) => {
@ -100,6 +110,10 @@ class View extends Component{
}); });
} }
/**
* Handle input box state changes
* @param {*} event Event data
*/
handleInputChange(event){ handleInputChange(event){
this.setState({ this.setState({
@ -108,6 +122,10 @@ class View extends Component{
} }
/**
* Handle checkbox state changes, make network updates
* @param {*} event Event data
*/
handleCheckChange(event){ handleCheckChange(event){
let payload = {...this.state.tag}; let payload = {...this.state.tag};
payload[event.target.name] = event.target.checked; payload[event.target.name] = event.target.checked;
@ -120,12 +138,20 @@ class View extends Component{
} }
} }
/**
* Put tag info changes to API
* @param {*} changes Dictionary of changes to submit
*/
makeNetworkUpdate(changes){ makeNetworkUpdate(changes){
axios.put(`/api/tag/${this.state.tag_id}`, changes).catch((error) => { axios.put(`/api/tag/${this.state.tag_id}`, changes).catch((error) => {
showMessage(`Error updating ${Object.keys(changes).join(", ")} (${error.response.status})`); showMessage(`Error updating ${Object.keys(changes).join(", ")} (${error.response.status})`);
}); });
} }
/**
* Validate input and make tag refresh update of API
* @param {*} event
*/
handleRun(event){ handleRun(event){
axios.get('/api/user') axios.get('/api/user')
.then((response) => { .then((response) => {
@ -145,6 +171,12 @@ class View extends Component{
}); });
} }
/**
* Handle remove watched part
* @param {*} music_obj Subject object to remove
* @param {*} addType Object type (tracks/albums/artists)
* @param {*} event Event data
*/
handleRemoveObj(music_obj, addType, event){ handleRemoveObj(music_obj, addType, event){
var startingItems = this.state.tag[addType].slice(); var startingItems = this.state.tag[addType].slice();
@ -177,12 +209,20 @@ class View extends Component{
}); });
} }
/**
* Handle adding type drop down change
* @param {*} type
*/
handleChangeAddType(type){ handleChangeAddType(type){
this.setState({ this.setState({
addType: type addType: type
}) })
} }
/**
* Validate input, make tag part add request of API
* @returns
*/
handleAdd(){ handleAdd(){
var addType = this.state.addType; var addType = this.state.addType;
@ -273,17 +313,27 @@ class View extends Component{
<div style={{maxWidth: '1000px', margin: 'auto', marginTop: '20px'}}> <div style={{maxWidth: '1000px', margin: 'auto', marginTop: '20px'}}>
<Card align="center"> <Card align="center">
<CardContent> <CardContent>
{/* TAG NAME TITLE */}
<Typography variant="h2" color="textPrimary">{this.state.tag.name}</Typography> <Typography variant="h2" color="textPrimary">{this.state.tag.name}</Typography>
<Grid container spacing={5}> <Grid container spacing={5}>
{/* ARTISTS TITLE */}
{ this.state.tag.artists.length > 0 && <Grid item xs={12} ><Typography color="textSecondary" variant="h4">Artists</Typography></Grid> } { this.state.tag.artists.length > 0 && <Grid item xs={12} ><Typography color="textSecondary" variant="h4">Artists</Typography></Grid> }
{/* ARTIST CARDS */}
{ this.state.tag.artists.length > 0 && <ListBlock handler={this.handleRemoveObj} list={this.state.tag.artists} addType="artists" showTime={this.state.tag.time_objects}/> } { this.state.tag.artists.length > 0 && <ListBlock handler={this.handleRemoveObj} list={this.state.tag.artists} addType="artists" showTime={this.state.tag.time_objects}/> }
{/* ALBUMS TITLE */}
{ this.state.tag.albums.length > 0 && <Grid item xs={12} ><Typography color="textSecondary" variant="h4">Albums</Typography></Grid> } { this.state.tag.albums.length > 0 && <Grid item xs={12} ><Typography color="textSecondary" variant="h4">Albums</Typography></Grid> }
{/* ALBUM CARDS */}
{ this.state.tag.albums.length > 0 && <ListBlock handler={this.handleRemoveObj} list={this.state.tag.albums} addType="albums" showTime={this.state.tag.time_objects}/> } { this.state.tag.albums.length > 0 && <ListBlock handler={this.handleRemoveObj} list={this.state.tag.albums} addType="albums" showTime={this.state.tag.time_objects}/> }
{/* TRACKS TITLE */}
{ this.state.tag.tracks.length > 0 && <Grid item xs={12} ><Typography color="textSecondary" variant="h4">Tracks</Typography></Grid> } { this.state.tag.tracks.length > 0 && <Grid item xs={12} ><Typography color="textSecondary" variant="h4">Tracks</Typography></Grid> }
{/* TRACK CARDS */}
{ this.state.tag.tracks.length > 0 && <ListBlock handler={this.handleRemoveObj} list={this.state.tag.tracks} addType="tracks" showTime={this.state.tag.time_objects}/> } { this.state.tag.tracks.length > 0 && <ListBlock handler={this.handleRemoveObj} list={this.state.tag.tracks} addType="tracks" showTime={this.state.tag.time_objects}/> }
{/* NAME TEXTBOX */}
<Grid item xs={12} sm={this.state.addType != 'artists' ? 3 : 4} md={this.state.addType != 'artists' ? 3 : 4}> <Grid item xs={12} sm={this.state.addType != 'artists' ? 3 : 4} md={this.state.addType != 'artists' ? 3 : 4}>
<TextField <TextField
name="name" name="name"
@ -292,16 +342,19 @@ class View extends Component{
value={this.state.name} value={this.state.name}
onChange={this.handleInputChange}></TextField> onChange={this.handleInputChange}></TextField>
</Grid> </Grid>
{/* ARTIST NAME TEXTBOX */}
{ this.state.addType != 'artists' && { this.state.addType != 'artists' &&
<Grid item xs={12} sm={3} md={4}> <Grid item xs={12} sm={3} md={4}>
<TextField <TextField
name="artist" name="artist"
label="Artist" label="Artist"
variant="filled" variant="filled"
value={this.state.artist} value={this.state.artist}
onChange={this.handleInputChange}></TextField> onChange={this.handleInputChange}></TextField>
</Grid> </Grid>
} }
{/* ADD TYPE DROPDOWN */}
<Grid item xs={12} sm={this.state.addType != 'artists' ? 2 : 4} md={this.state.addType != 'artists' ? 2 : 4}> <Grid item xs={12} sm={this.state.addType != 'artists' ? 2 : 4} md={this.state.addType != 'artists' ? 2 : 4}>
<FormControl variant="filled"> <FormControl variant="filled">
<InputLabel htmlFor="addType">Type</InputLabel> <InputLabel htmlFor="addType">Type</InputLabel>
@ -319,9 +372,13 @@ class View extends Component{
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
{/* ADD BUTTON */}
<Grid item xs={12} sm={this.state.addType != 'artists' ? 3 : 4} md={this.state.addType != 'artists' ? 3 : 4}> <Grid item xs={12} sm={this.state.addType != 'artists' ? 3 : 4} md={this.state.addType != 'artists' ? 3 : 4}>
<Button variant="contained" onClick={this.handleAdd} className="full-width">Add</Button> <Button variant="contained" onClick={this.handleAdd} className="full-width">Add</Button>
</Grid> </Grid>
{/* TIME CHECKBOX */}
<Grid item xs={12}> <Grid item xs={12}>
<FormControlLabel <FormControlLabel
control={ control={
@ -331,15 +388,22 @@ class View extends Component{
labelPlacement="bottom" labelPlacement="bottom"
/> />
</Grid> </Grid>
{/* STATS CARD */}
<StatsCard count={this.state.tag.count} proportion={this.state.tag.proportion} showTime={this.state.tag.time_objects} time={this.state.tag.total_time}></StatsCard> <StatsCard count={this.state.tag.count} proportion={this.state.tag.proportion} showTime={this.state.tag.time_objects} time={this.state.tag.total_time}></StatsCard>
{/* PIE CHART */}
<Grid item xs={12}> <Grid item xs={12}>
<PieChart data={data}/> <PieChart data={data} padding={100}/>
</Grid> </Grid>
{/* BAR CHART */}
<Grid item xs={12}> <Grid item xs={12}>
<BarChart data={data} title='scrobbles'/> <BarChart data={data} title='scrobbles' indexAxis='y'/>
</Grid> </Grid>
</Grid> </Grid>
</CardContent> </CardContent>
{/* UPDATE BUTTON */}
<CardActions> <CardActions>
<Button onClick={this.handleRun} variant="contained" color="primary" className="full-width" >Update</Button> <Button onClick={this.handleRun} variant="contained" color="primary" className="full-width" >Update</Button>
</CardActions> </CardActions>
@ -353,6 +417,11 @@ class View extends Component{
export default View; export default View;
/**
* Grid component for holding artist/album/track cards
* @param {*} props Properties
* @returns Grid component
*/
function ListBlock(props) { function ListBlock(props) {
return <Grid container return <Grid container
spacing={3} spacing={3}
@ -365,6 +434,11 @@ function ListBlock(props) {
</Grid> </Grid>
} }
/**
* Track/album/artist card with time info and delete button
* @param {*} props Properties
* @returns Card component wrapped in grid cell
*/
function BlockGridItem (props) { function BlockGridItem (props) {
const classes = useStyles(); const classes = useStyles();
return ( return (
@ -372,19 +446,26 @@ function BlockGridItem (props) {
<Card variant="outlined" className={classes.root}> <Card variant="outlined" className={classes.root}>
<CardContent> <CardContent>
<Grid> <Grid>
{/* NAME TITLE */}
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="h4" color="textSecondary" className={classes.root}>{ props.music_obj.name }</Typography> <Typography variant="h4" color="textSecondary" className={classes.root}>{ props.music_obj.name }</Typography>
</Grid> </Grid>
{/* ARTIST NAME */}
{ 'artist' in props.music_obj && { 'artist' in props.music_obj &&
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="body1" color="textSecondary" className={classes.root}>{ props.music_obj.artist }</Typography> <Typography variant="body1" color="textSecondary" className={classes.root}>{ props.music_obj.artist }</Typography>
</Grid> </Grid>
} }
{/* SCROBBLE COUNT */}
{ 'count' in props.music_obj && { 'count' in props.music_obj &&
<Grid item xs={8}> <Grid item xs={8}>
<Typography variant="h4" color="textPrimary" className={classes.root}>📈 { props.music_obj.count }</Typography> <Typography variant="h4" color="textPrimary" className={classes.root}>📈 { props.music_obj.count }</Typography>
</Grid> </Grid>
} }
{/* TIME */}
{ 'time' in props.music_obj && props.showTime && { 'time' in props.music_obj && props.showTime &&
<Grid item xs={8}> <Grid item xs={8}>
<Typography variant="body1" color="textSecondary" className={classes.root}>🕒 { props.music_obj.time }</Typography> <Typography variant="body1" color="textSecondary" className={classes.root}>🕒 { props.music_obj.time }</Typography>
@ -392,6 +473,8 @@ function BlockGridItem (props) {
} }
</Grid> </Grid>
</CardContent> </CardContent>
{/* DELETE BUTTON */}
<CardActions> <CardActions>
<Button className="full-width" color="secondary" variant="contained" aria-label="delete" onClick={(e) => props.handler(props.music_obj, props.addType, e)} startIcon={<Delete />}> <Button className="full-width" color="secondary" variant="contained" aria-label="delete" onClick={(e) => props.handler(props.music_obj, props.addType, e)} startIcon={<Delete />}>
Delete Delete
@ -402,6 +485,11 @@ function BlockGridItem (props) {
); );
} }
/**
* Stats card component with total time and scrobbles
* @param {*} props Properties
* @returns Card component wrapped in grid cell
*/
function StatsCard (props) { function StatsCard (props) {
const classes = useStyles(); const classes = useStyles();
return ( return (
@ -409,12 +497,18 @@ function StatsCard (props) {
<Card variant="outlined" className={classes.root}> <Card variant="outlined" className={classes.root}>
<CardContent> <CardContent>
<Grid container spacing={10}> <Grid container spacing={10}>
{/* SCROBBLE COUNT */}
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="h1" color="textPrimary" className={classes.root}>📈 { props.count }</Typography> <Typography variant="h1" color="textPrimary" className={classes.root}>📈 { props.count }</Typography>
</Grid> </Grid>
{/* PERCENT */}
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="h4" color="textSecondary" className={classes.root}>{ props.proportion.toFixed(2) }%</Typography> <Typography variant="h4" color="textSecondary" className={classes.root}>{ props.proportion.toFixed(2) }%</Typography>
</Grid> </Grid>
{/* TOTAL TIME */}
{props.showTime && {props.showTime &&
<Grid item xs={12}> <Grid item xs={12}>
<Typography variant="h4" color="textSecondary" className={classes.root}>🕒 { props.time }</Typography> <Typography variant="h4" color="textSecondary" className={classes.root}>🕒 { props.time }</Typography>

View File

@ -3,4 +3,6 @@ import ReactDOM from "react-dom";
import MusicTools from "./MusicTools"; import MusicTools from "./MusicTools";
// ROOT file for bootstrapping the Music Tools component and app
ReactDOM.render(<MusicTools />, document.getElementById('react')); ReactDOM.render(<MusicTools />, document.getElementById('react'));

View File

@ -1,4 +1,6 @@
// login function for validating login data
function handleLogin(){ function handleLogin(){
var username = document.forms['login']['username'].value; var username = document.forms['login']['username'].value;
var password = document.forms['login']['password'].value; var password = document.forms['login']['password'].value;

View File

@ -1,4 +1,6 @@
// login function for validating register data
function handleRegister(){ function handleRegister(){
var username = document.forms['register']['username'].value; var username = document.forms['register']['username'].value;
var password = document.forms['register']['password'].value; var password = document.forms['register']['password'].value;