├── .gitignore
├── Dockerfile
├── README.md
├── client
├── .env.development
├── .env.production
├── .gitignore
├── Dockerfile
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ └── manifest.json
├── src
│ ├── components
│ │ ├── account-data
│ │ │ ├── account-data.css
│ │ │ └── index.jsx
│ │ ├── account-dropdown
│ │ │ ├── account-dropdown.css
│ │ │ └── index.jsx
│ │ ├── account-signin
│ │ │ ├── account-signin.css
│ │ │ └── index.jsx
│ │ ├── app
│ │ │ ├── app.css
│ │ │ └── index.jsx
│ │ ├── bucket-container
│ │ │ ├── bucket-container.css
│ │ │ └── index.jsx
│ │ ├── bucket-options
│ │ │ ├── bucket-options.css
│ │ │ └── index.jsx
│ │ ├── bucket-row
│ │ │ ├── bucket-row.css
│ │ │ ├── half.png
│ │ │ ├── half.svg
│ │ │ ├── index.jsx
│ │ │ └── sixteenth.svg
│ │ ├── bucket
│ │ │ ├── bucket.css
│ │ │ └── index.jsx
│ │ ├── create-account-form
│ │ │ ├── create-account-form.css
│ │ │ └── index.jsx
│ │ ├── delete-share-save
│ │ │ ├── delete-share-save.css
│ │ │ └── index.jsx
│ │ ├── editable-text
│ │ │ ├── editable-text.css
│ │ │ └── index.jsx
│ │ ├── envelope
│ │ │ ├── envelope.css
│ │ │ └── index.jsx
│ │ ├── filter
│ │ │ ├── filter.css
│ │ │ └── index.jsx
│ │ ├── header
│ │ │ ├── header.css
│ │ │ └── index.jsx
│ │ ├── input-with-message
│ │ │ ├── index.jsx
│ │ │ └── input-with-message.css
│ │ ├── keyboard
│ │ │ ├── index.jsx
│ │ │ └── keyboard.css
│ │ ├── logarithmic-slider
│ │ │ ├── index.jsx
│ │ │ └── logarithmic-slider.css
│ │ ├── note-column
│ │ │ ├── index.jsx
│ │ │ └── note-column.css
│ │ ├── note-in-bucket
│ │ │ ├── index.jsx
│ │ │ └── note-in-bucket.css
│ │ ├── note-in-keyboard
│ │ │ ├── index.jsx
│ │ │ └── note-in-keyboard.css
│ │ ├── note
│ │ │ ├── index.jsx
│ │ │ └── note.css
│ │ ├── oscillator
│ │ │ ├── index.jsx
│ │ │ └── oscillator.css
│ │ ├── play-bpm
│ │ │ ├── index.jsx
│ │ │ └── play-bpm.css
│ │ ├── project-buttons
│ │ │ ├── index.jsx
│ │ │ └── project-buttons.css
│ │ ├── project
│ │ │ ├── index.jsx
│ │ │ └── project.css
│ │ ├── rc-footer
│ │ │ ├── index.jsx
│ │ │ └── rc-footer.css
│ │ ├── sharing
│ │ │ ├── index.jsx
│ │ │ └── sharing.css
│ │ ├── signin-form
│ │ │ ├── index.jsx
│ │ │ └── signin-form.css
│ │ ├── svg
│ │ │ ├── blank-square.jsx
│ │ │ ├── clipboard.jsx
│ │ │ ├── copy.jsx
│ │ │ ├── sawtooth-svg.jsx
│ │ │ ├── sine-svg.jsx
│ │ │ ├── square-svg.jsx
│ │ │ ├── three-dots-svg.jsx
│ │ │ ├── triangle-svg.jsx
│ │ │ └── x-square.jsx
│ │ ├── track-info
│ │ │ ├── index.jsx
│ │ │ └── track-info.css
│ │ ├── track-options
│ │ │ ├── index.jsx
│ │ │ └── track-options.css
│ │ ├── track
│ │ │ ├── index.jsx
│ │ │ └── track.css
│ │ └── tracks
│ │ │ ├── index.jsx
│ │ │ └── tracks.css
│ ├── dnd
│ │ └── item-types.js
│ ├── index.css
│ ├── index.js
│ ├── redux
│ │ ├── actions
│ │ │ ├── actions-clipboard.js
│ │ │ ├── actions-project.js
│ │ │ ├── actions-sequence.js
│ │ │ ├── actions-synth.js
│ │ │ ├── actions-track.js
│ │ │ ├── actions-tracks.js
│ │ │ └── actions-user.js
│ │ ├── default-data
│ │ │ └── index.js
│ │ ├── observers
│ │ │ └── index.js
│ │ ├── reducers
│ │ │ ├── index.js
│ │ │ ├── reducer-bucket.js
│ │ │ ├── reducer-clipboard.js
│ │ │ ├── reducer-notes.js
│ │ │ ├── reducer-project.js
│ │ │ ├── reducer-sequence.js
│ │ │ ├── reducer-synth.js
│ │ │ ├── reducer-track.js
│ │ │ ├── reducer-tracks.js
│ │ │ └── reducer-user.js
│ │ └── selectors
│ │ │ └── index.js
│ ├── registerServiceWorker.js
│ ├── sequencer
│ │ ├── index.js
│ │ ├── track.js
│ │ ├── utils.js
│ │ └── utils.test.js
│ └── utils
│ │ └── index.js
└── yarn.lock
├── docker-compose-web.yml
├── docker-compose.yml
└── server
├── Dockerfile
├── Pipfile
├── Pipfile.lock
├── api
├── __init__.py
├── auth.py
├── config.py
├── db
│ ├── __init__.py
│ ├── auth.py
│ ├── init_db.py
│ ├── project.py
│ └── track.py
└── views
│ ├── auth.py
│ └── resource.py
├── run.py
└── tests
├── dummy_data.py
├── fixtures.py
├── test_auth.py
├── test_authenticate.py
├── test_login.py
├── test_project.py
├── test_projects.py
├── test_register.py
├── test_save.py
├── test_testing.py
└── utils.py
/.gitignore:
--------------------------------------------------------------------------------
1 | #cache
2 | __pycache__
3 | .cache
4 | .pytest_cache
5 |
6 | # npm stuff
7 | node_modules/
8 | npm-debug.log
9 |
10 | # mac
11 | .DS_Store
12 |
13 | #sublime files
14 | **.sublime-project
15 | **.sublime-workspace
16 |
17 | # personal notes
18 | joe.md
19 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # this Dockerfile is for deployment of ./server to Heroku
2 |
3 | FROM python:3.6.1
4 |
5 | # install pipenv
6 | RUN pip install pipenv
7 |
8 | # set working directory
9 | WORKDIR /usr/src/app
10 |
11 | # add Pipfile
12 | COPY ./server/Pipfile ./
13 | COPY ./server/Pipfile.lock ./
14 |
15 | # generate requirements.txt and install dependencies
16 | # trying to do this with `pipenv install` leads to permissions issues w/ Heroku
17 | RUN pipenv lock --requirements > requirements.txt
18 | RUN pip install -r requirements.txt
19 |
20 | # add app
21 | COPY ./server .
22 |
23 | ENV SECRET_KEY=${SECRET_KEY} DATABASE_URL=${DATABASE_URL} APP_SETTINGS="api.config.ProdConfig"
24 |
25 | RUN useradd -m myuser
26 | USER myuser
27 |
28 | CMD gunicorn -b 0.0.0.0:$PORT run:app
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Beat Bucket
2 |
3 | [**Beat Bucket**](https://joebeachjoebeach.github.io/beat-bucket) is a browser-based synthesizer sequencer with a unique interface.
4 |
5 | **Demo:**
6 | 
7 |
8 |
9 | **Here's what makes it special:**
10 | * Like a drum machine, each track is represented by a series of beats. However, each beat is a 'bucket' that can contain multiple notes.
11 | * If a beat has multiple notes in it, the notes are played in sequence, and the beat is automatically subdivided by the number of notes within it.
12 | * e.g. a quarter-note beat with two notes in it will play them as eighth notes
13 | * e.g. a quarter-note beat with three notes in it will play them as triplets
14 | * you can fill up a beat with as many notes as you like, and it will subdivide the beat accordingly
15 | * Each track loops according to the number of beats in the track.
16 | * e.g. a five-beat track will loop every five beats
17 | * e.g. a four-beat track will loop every four beats.
18 | * (this allows for some neat rhythmic cycle opportunities)
19 | * Each track can be assigned its own beat value.
20 | * e.g. one track can have each beat be a half note, while another has each beat be a sixteenth note.
21 |
22 | **Features:**
23 | * Save, share, and delete projects with a user account
24 | * Modify projects
25 | * Rename projects and tracks
26 | * Edit project BPM
27 | * Add and remove tracks
28 | * Modify tracks
29 | * Add and remove beats (buckets)
30 | * Assign beat value for individual tracks
31 | * Mute and solo tracks
32 | * Edit track volume
33 | * Modify track synthesizer
34 | * ADSR envelope
35 | * Oscillator selection (sine, square, triangle, saw)
36 | * Filters (high pass, low pass, band pass)
37 |
38 | ---
39 |
40 | **Tools used to make this:**
41 | * Front end:
42 | * React
43 | * React-dnd
44 | * Redux
45 | * Tone.js
46 | * Deployed to GitHub Pages via `gh-pages`
47 | * Back end:
48 | * Flask
49 | * PostgreSQL
50 | * Deployed to Heroku as a Docker container
51 | * Development environment:
52 | * Docker + Docker Compose
53 |
--------------------------------------------------------------------------------
/client/.env.development:
--------------------------------------------------------------------------------
1 | REACT_APP_API_URL=http://localhost:5000/
2 | REACT_APP_WEB_URL=http://localhost:3000/
3 |
--------------------------------------------------------------------------------
/client/.env.production:
--------------------------------------------------------------------------------
1 | REACT_APP_API_URL=https://beat-bucket.herokuapp.com/
2 | REACT_APP_WEB_URL=https://beatbucket.io/
3 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/client/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:latest
2 |
3 | # set working directory
4 | WORKDIR /usr/src/app
5 |
6 | # copy yarn stuff
7 | COPY package.json ./
8 | COPY yarn.lock ./
9 |
10 | RUN yarn install -s
11 |
12 | COPY . .
13 |
14 | EXPOSE 3000
15 |
16 | # start app
17 | CMD ["yarn", "start"]
18 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "beat-bucket-client",
3 | "homepage": "https://beatbucket.io",
4 | "version": "0.1.0",
5 | "private": true,
6 | "dependencies": {
7 | "axios": "^0.17.1",
8 | "gh-pages": "^1.1.0",
9 | "lodash": "^4.17.4",
10 | "normalize.css": "^7.0.0",
11 | "react": "^16.2.0",
12 | "react-dnd": "^2.5.4",
13 | "react-dnd-html5-backend": "^2.5.4",
14 | "react-dom": "^16.2.0",
15 | "react-redux": "^5.0.6",
16 | "react-router-dom": "^4.2.2",
17 | "react-scripts": "1.0.17",
18 | "redux": "^3.7.2",
19 | "tone": "^0.11.11",
20 | "uuid": "^3.2.1"
21 | },
22 | "scripts": {
23 | "predeploy": "yarn build",
24 | "deploy": "gh-pages -d build",
25 | "start": "react-scripts start",
26 | "build": "react-scripts build",
27 | "test": "react-scripts test --env=jsdom",
28 | "eject": "react-scripts eject"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joebeachjoebeach/beat-bucket/f4314be76e8c1f1afebd8b0ff934fc834dfaa2de/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Beat Bucket
11 |
12 |
13 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/client/src/components/account-data/account-data.css:
--------------------------------------------------------------------------------
1 | .account-data {
2 | display: flex;
3 | flex-direction: column;
4 | padding: 10px;
5 | }
6 |
7 | .project-list {
8 | border-bottom: 1px solid #444;
9 | display: flex;
10 | flex-direction: column;
11 | margin: 0 0 10px 0;
12 | padding: 0 5px 15px 5px;
13 | }
14 |
15 | .project-list-header {
16 | color: #888;
17 | margin: 0 0 10px 0;
18 | }
19 |
20 | .project-list-button {
21 | background: transparent;
22 | border: none;
23 | color: #DDD;
24 | padding: 3px 5px;
25 | text-decoration: none;
26 | text-align: left;
27 | }
28 |
29 | .project-list-button:hover {
30 | background-color: #444;
31 | background-color: rgba(0, 0, 0, 0.2);
32 | cursor: pointer;
33 | }
34 |
--------------------------------------------------------------------------------
/client/src/components/account-data/index.jsx:
--------------------------------------------------------------------------------
1 | // ACCOUNT-DATA
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { Link } from 'react-router-dom';
7 | import { setUser } from '../../redux/actions/actions-user';
8 | import { loadProject } from '../../redux/actions/actions-project';
9 | import { selectProjects } from '../../redux/selectors';
10 | import { resourceRequest } from '../../utils';
11 | import './account-data.css';
12 |
13 | function AccountData({ projects, setUser, hideDropDown, loadProject}) {
14 |
15 | function handleSignOut() {
16 | localStorage.removeItem('refreshToken');
17 | localStorage.removeItem('accessToken');
18 | setUser({ email: null, userId: null });
19 | hideDropDown();
20 | }
21 |
22 | function handleProjectClick(id) {
23 | return () => {
24 | resourceRequest('get', `project/${id}`, {
25 | success: res => {
26 | loadProject({ data: res.data.project, id: res.data.id });
27 | hideDropDown();
28 | },
29 | failure: err => {
30 | const { error } = err.response.data;
31 | if (error === 'Invalid token')
32 | handleSignOut();
33 | }
34 | });
35 | };
36 | }
37 |
38 | function renderProjectsList() {
39 | return Object.keys(projects).map(id => {
40 | const name = projects[id];
41 | return (
42 |
47 | {name}
48 |
49 | );
50 | }).reverse();
51 | }
52 |
53 | return (
54 |
55 |
56 |
57 | {renderProjectsList()}
58 |
59 |
65 |
66 | );
67 | }
68 |
69 | function mapStateToProps(state) {
70 | return { projects: selectProjects(state) };
71 | }
72 |
73 | function mapDispatchToProps(dispatch) {
74 | return bindActionCreators({ setUser, loadProject }, dispatch);
75 | }
76 |
77 | export default connect(mapStateToProps, mapDispatchToProps)(AccountData);
78 |
--------------------------------------------------------------------------------
/client/src/components/account-dropdown/account-dropdown.css:
--------------------------------------------------------------------------------
1 | .account-dropdown {
2 | background-color: #22262A;
3 | position: fixed;
4 | right: 0;
5 | top: 48px;
6 | width: 250px;
7 | z-index: 1000;
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/components/account-dropdown/index.jsx:
--------------------------------------------------------------------------------
1 | // ACCOUNT-DROPDOWN
2 |
3 | import React from 'react';
4 | import './account-dropdown.css';
5 |
6 | import AccountSignin from '../account-signin';
7 | import AccountData from '../account-data';
8 |
9 | const AccountDropdown = ({ email, hideDropDown }) => {
10 |
11 | return (
12 |
13 | {email
14 | ?
15 | :
}
16 |
17 | );
18 | };
19 |
20 | export default AccountDropdown;
21 |
--------------------------------------------------------------------------------
/client/src/components/account-signin/account-signin.css:
--------------------------------------------------------------------------------
1 | .account-signin {
2 | height: 100%;
3 | margin: 10px 20px;
4 | }
5 |
6 | .signin-container {
7 | display: flex;
8 | flex-direction: column;
9 | height: 80%;
10 | justify-content: space-between;
11 | }
12 |
13 | .signin-form {
14 | display: flex;
15 | flex-direction: column;
16 | margin: 0 0 30px 0;
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/components/account-signin/index.jsx:
--------------------------------------------------------------------------------
1 | // ACCOUNT-SIGNIN
2 |
3 | import React, { Component } from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { setUser, loadProjects } from '../../redux/actions/actions-user.js';
7 | import { resourceRequest, signIn, register } from '../../utils';
8 | import './account-signin.css';
9 |
10 | import CreateAccountForm from '../create-account-form';
11 | import SigninForm from '../signin-form';
12 |
13 | class AccountSignin extends Component {
14 | constructor(props) {
15 | super(props);
16 | this.state = {
17 | createAccount: false,
18 | signinMessage: '',
19 | createAccountMessage: '',
20 | loading: false
21 | };
22 | this.toggleCreateAccount = this.toggleCreateAccount.bind(this);
23 | this.handleSignInSubmit = this.handleSignInSubmit.bind(this);
24 | this.handleCreateAccountSubmit = this.handleCreateAccountSubmit.bind(this);
25 | }
26 |
27 | toggleCreateAccount() {
28 | this.setState(prevState => ({ createAccount: !prevState.createAccount }));
29 | }
30 |
31 | handleCreateAccountSubmit(email, password1, password2) {
32 | if (password1 === password2) {
33 | email = email.trim();
34 | this.setState({ loading: true });
35 | register(
36 | { email, password: password1 },
37 | {
38 | success: () => {
39 | this.setState({
40 | createAccount: false,
41 | signinMessage: 'Account created. You may now sign in.',
42 | loading: false
43 | });
44 | },
45 | failure: err => {
46 | this.setState({
47 | createAccountMessage: err.response.data.error,
48 | loading: false
49 | });
50 | }
51 | }
52 | );
53 | }
54 | }
55 |
56 | handleSignInSubmit(email, password) {
57 | email = email.trim();
58 | const { setUser, hideDropDown, loadProjects } = this.props;
59 | this.setState({ loading: true });
60 | signIn(
61 | { email, password },
62 | {
63 | success: (res) => {
64 | this.setState({ loading: false });
65 | hideDropDown();
66 | setUser({ email: res.data.email, id: res.data.userId });
67 | resourceRequest('get', 'projects', {
68 | success: res => { loadProjects(res.data.projects); },
69 | failure: () => { return; },
70 | authFailure: () => { return; }
71 | });
72 | },
73 | failure: err => {
74 | let errorMessage;
75 | if (err.response)
76 | errorMessage = err.response.data.error;
77 | this.setState({
78 | signinMessage: errorMessage,
79 | loading: false
80 | });
81 | }
82 | }
83 | );
84 | }
85 |
86 | render() {
87 | const { signinMessage, createAccountMessage, loading } = this.state;
88 | return (
89 |
90 | {
91 | this.state.createAccount
92 | ?
98 | :
104 | }
105 |
106 | );
107 | }
108 | }
109 |
110 |
111 | function mapStateToProps(state) {
112 | return state;
113 | }
114 |
115 | function mapDispatchToProps(dispatch) {
116 | return bindActionCreators({ setUser, loadProjects }, dispatch);
117 | }
118 |
119 | export default connect(mapStateToProps, mapDispatchToProps)(AccountSignin);
120 |
--------------------------------------------------------------------------------
/client/src/components/app/app.css:
--------------------------------------------------------------------------------
1 | .app {
2 | background: linear-gradient(to bottom right, #1b1e21, #3d434b);
3 | display: flex;
4 | flex-direction: column;
5 | height: 100%;
6 | width: 100%;
7 | }
8 |
9 | .btn {
10 | background-color: #04395E;
11 | border: none;
12 | border-radius: 0;
13 | color: white;
14 | font-size: 1rem;
15 | }
16 |
17 | .wrapper {
18 | display: flex;
19 | flex: auto;
20 | min-height: 0;
21 | width: 100%;
22 | }
23 |
24 | .subwrapper {
25 | height: 100%;
26 | width: 100%;
27 | }
28 |
--------------------------------------------------------------------------------
/client/src/components/app/index.jsx:
--------------------------------------------------------------------------------
1 | // APP
2 |
3 | import React from 'react';
4 | import { DragDropContext } from 'react-dnd';
5 | import HTML5Backend from 'react-dnd-html5-backend';
6 | import { connect } from 'react-redux';
7 | import { bindActionCreators } from 'redux';
8 | import { setUser, loadProjects } from '../../redux/actions/actions-user.js';
9 | import { loadProject } from '../../redux/actions/actions-project';
10 | import { useRefreshToken, resourceRequest, getSharedProject } from '../../utils';
11 | import './app.css';
12 |
13 | import Header from '../header';
14 | import Project from '../project';
15 | import NoteColumn from '../note-column';
16 | import RCFooter from '../rc-footer';
17 |
18 | class App extends React.Component {
19 | constructor(props) {
20 | super(props);
21 | this.state = {
22 | loading: props.match.path !== '/',
23 | message: 'Loading...',
24 | error: false
25 | };
26 | }
27 |
28 | componentDidMount() {
29 | // use the refresh token to authenticate (if it exists)
30 | const { loadProjects, setUser } = this.props;
31 | useRefreshToken({
32 | success: res => {
33 | const { email, userId } = res.data;
34 | setUser({ email, id: userId });
35 | resourceRequest('get', 'projects', {
36 | success: res => { loadProjects(res.data.projects); },
37 | failure: () => { return; },
38 | authFailure: () => { return; }
39 | });
40 | },
41 | });
42 |
43 | // if we're at a /share/:id url, try to fetch the project
44 | const { path, params } = this.props.match;
45 | if (path === '/share/:id') {
46 | getSharedProject(params.id, {
47 | success: res => {
48 | res.data.project.shared = false;
49 | this.props.loadProject({ data: res.data.project, id: null });
50 | this.setState({ loading: false });
51 | },
52 | failure: () => {
53 | this.setState({
54 | message: 'Oops, we can\'t find that shared project. Please check the URL.',
55 | error: true
56 | });
57 | }
58 | });
59 | }
60 | }
61 |
62 | render() {
63 | const { loading, message, error } = this.state;
64 | if (loading || error) {
65 | return (
66 |
67 |
68 |
69 |
70 |
71 | {message}
72 |
73 |
74 |
75 |
76 |
77 |
78 | );
79 | }
80 | return (
81 |
89 | );
90 |
91 | }
92 | }
93 |
94 | function mapDispatchToProps(dispatch) {
95 | return bindActionCreators({ setUser, loadProject, loadProjects }, dispatch);
96 | }
97 |
98 | const ddc_App = DragDropContext(HTML5Backend)(App);
99 | export default connect(null, mapDispatchToProps)(ddc_App);
100 |
--------------------------------------------------------------------------------
/client/src/components/bucket-container/bucket-container.css:
--------------------------------------------------------------------------------
1 | .bucket-container {
2 | display: flex;
3 | flex-direction: column;
4 | position: relative;
5 | }
6 |
7 | .deletebucket-container {
8 | height: 20px;
9 | }
10 |
11 | .delete-bucket {
12 | align-items: center;
13 | display: flex;
14 | font-size: 0.8rem;
15 | height: 20px;
16 | justify-content: center;
17 | margin: 5px 0 0 0;
18 | width: 30px;
19 | }
20 |
21 | .three-dots {
22 | height: 20px;
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/components/bucket-container/index.jsx:
--------------------------------------------------------------------------------
1 | // BUCKET-CONTAINER
2 |
3 | import React, { Component } from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { deleteBucket } from '../../redux/actions/actions-sequence';
7 | import './bucket-container.css';
8 |
9 | import Bucket from '../bucket';
10 | import ThreeDotsSVG from '../svg/three-dots-svg.jsx';
11 | import BucketOptions from '../bucket-options';
12 |
13 | class BucketContainer extends Component {
14 | constructor(props) {
15 | super(props);
16 | this.state = { hover: false, showOptions: false };
17 | this.handleThreeDotsClick = this.handleThreeDotsClick.bind(this);
18 | this.handleMouseEnter = this.handleMouseEnter.bind(this);
19 | this.handleMouseLeave = this.handleMouseLeave.bind(this);
20 | this.hideOptions = this.hideOptions.bind(this);
21 | }
22 |
23 | handleThreeDotsClick() {
24 | this.setState(prevState => ({
25 | showOptions: !prevState.showOptions
26 | }));
27 | }
28 |
29 | handleMouseEnter() {
30 | this.setState({ hover: true });
31 | }
32 |
33 | handleMouseLeave() {
34 | this.setState({ hover: false, showOptions: false });
35 | }
36 |
37 | hideOptions() {
38 | this.setState({ showOptions: false });
39 | }
40 |
41 | render() {
42 | const { hover, showOptions } = this.state;
43 | const { trackId, bucketId, notes } = this.props;
44 | return (
45 |
50 |
51 |
52 | {hover && }
59 |
60 | {showOptions && hover &&
61 |
67 | }
68 |
69 | );
70 | }
71 | }
72 |
73 | function mapDispatchToProps(dispatch) {
74 | return bindActionCreators({ deleteBucket }, dispatch);
75 | }
76 |
77 | export default connect(null, mapDispatchToProps)(BucketContainer);
78 |
--------------------------------------------------------------------------------
/client/src/components/bucket-options/bucket-options.css:
--------------------------------------------------------------------------------
1 | .bucket-options {
2 | background-color: #222;
3 | background-color: rgba(0, 0, 0, 0.7);
4 | display: flex;
5 | flex-direction: column;
6 | height: 125px;
7 | position: absolute;
8 | top: -5px;
9 | width: 100%;
10 | }
11 |
12 | .bucket-options-item {
13 | align-items: center;
14 | background-color: transparent;
15 | border: none;
16 | color: inherit;
17 | display: flex;
18 | margin: 5px 0 0 5px;
19 | padding: 0;
20 | }
21 |
22 | .bucket-options-item:hover {
23 | color: #C94E2C;
24 | cursor: pointer;
25 | }
26 |
27 | .bucket-options-svg {
28 | margin: 0 3px 0 0;
29 | width: 20px;
30 | }
31 |
--------------------------------------------------------------------------------
/client/src/components/bucket-options/index.jsx:
--------------------------------------------------------------------------------
1 | // BUCKET-OPTIONS
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { deleteBucket, clearBucket } from '../../redux/actions/actions-sequence';
7 | import { copyBucket, pasteBucket } from '../../redux/actions/actions-clipboard';
8 | import { selectClipboard } from '../../redux/selectors';
9 | import './bucket-options.css';
10 |
11 | import BlankSquare from '../svg/blank-square';
12 | import Clipboard from '../svg/clipboard';
13 | import Copy from '../svg/copy';
14 | import XSquare from '../svg/x-square';
15 |
16 | const BucketOptions = ({
17 | trackId,
18 | bucketId,
19 | notes,
20 | hideParentOptions,
21 | clipboard,
22 | deleteBucket,
23 | clearBucket,
24 | copyBucket,
25 | pasteBucket }) => {
26 |
27 | function handleDeleteBucketClick() {
28 | deleteBucket({ trackId, bucketId });
29 | hideParentOptions();
30 | }
31 |
32 | function handleClearBucketClick() {
33 | clearBucket({ trackId, bucketId });
34 | hideParentOptions();
35 | }
36 |
37 | function handleCopyBucketClick() {
38 | copyBucket(notes);
39 | hideParentOptions();
40 | }
41 |
42 | function handlePasteBucketClick() {
43 | pasteBucket({ trackId, bucketId, notes: clipboard });
44 | hideParentOptions();
45 | }
46 |
47 | return (
48 |
49 | {clipboard.length > 0 &&
50 |
57 | }
58 |
59 | {notes.length > 0 &&
60 |
67 | }
68 |
69 |
76 |
77 | {notes.length > 0 &&
78 |
85 | }
86 |
87 | );
88 | };
89 |
90 | function mapStateToProps(state) {
91 | return { clipboard: selectClipboard(state) };
92 | }
93 |
94 | function mapDispatchToProps(dispatch) {
95 | const actions = { deleteBucket, clearBucket, copyBucket, pasteBucket };
96 | return bindActionCreators(actions, dispatch);
97 | }
98 |
99 | export default connect(mapStateToProps, mapDispatchToProps)(BucketOptions);
100 |
--------------------------------------------------------------------------------
/client/src/components/bucket-row/bucket-row.css:
--------------------------------------------------------------------------------
1 | .bucketrow {
2 | display: flex;
3 | height: 100%;
4 | }
5 |
6 | .bucketrow-button {
7 | font-size: 1.5rem;
8 | height: 50px;
9 | margin: 20px;
10 | width: 35px;
11 | }
12 |
13 | .sixteenth {
14 | height: 65%;
15 | width: 65%;
16 | }
17 |
18 | .half {
19 | height: 50%;
20 | width: 50%;
21 | }
22 |
--------------------------------------------------------------------------------
/client/src/components/bucket-row/half.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joebeachjoebeach/beat-bucket/f4314be76e8c1f1afebd8b0ff934fc834dfaa2de/client/src/components/bucket-row/half.png
--------------------------------------------------------------------------------
/client/src/components/bucket-row/half.svg:
--------------------------------------------------------------------------------
1 |
2 |
87 |
--------------------------------------------------------------------------------
/client/src/components/bucket-row/index.jsx:
--------------------------------------------------------------------------------
1 | // BUCKETROW
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { addBucket } from '../../redux/actions/actions-sequence';
7 | import { changeBaseNote } from '../../redux/actions/actions-track';
8 | import { selectBaseNote } from '../../redux/selectors';
9 | import halfNote from './half.svg';
10 | import sixteenthNote from './sixteenth.svg';
11 | import './bucket-row.css';
12 |
13 | import BucketContainer from '../bucket-container';
14 |
15 | const BucketRow = ({
16 | sequence,
17 | currentNote,
18 | id,
19 | addBucket,
20 | baseNote,
21 | changeBaseNote }) => {
22 |
23 | function handleAddBucketClick() {
24 | addBucket({ trackId: id });
25 | }
26 |
27 | function handleBaseNoteClick() {
28 | const payload = baseNote <= 1 ? 8 : baseNote / 2;
29 | changeBaseNote({ baseNote: payload, trackId: id });
30 | }
31 |
32 | function renderNoteSymbol() {
33 | switch (baseNote) {
34 | case 8:
35 | return
;
36 | case 4:
37 | return '♩';
38 | case 2:
39 | return '♪';
40 | case 1:
41 | return
;
42 | default:
43 | return 4;
44 | }
45 | }
46 |
47 | function renderBuckets() {
48 | return sequence.map((bucket, i) => {
49 | return ;
56 | });
57 | }
58 |
59 | return (
60 |
61 |
68 | {renderBuckets()}
69 |
76 |
77 | );
78 | };
79 |
80 | function mapStateToProps(state, ownProps) {
81 | return { baseNote: selectBaseNote(ownProps.id)(state) };
82 | }
83 |
84 | function mapDispatchToProps(dispatch) {
85 | return bindActionCreators({ addBucket, changeBaseNote }, dispatch);
86 | }
87 |
88 | export default connect(mapStateToProps, mapDispatchToProps)(BucketRow);
89 |
--------------------------------------------------------------------------------
/client/src/components/bucket-row/sixteenth.svg:
--------------------------------------------------------------------------------
1 |
2 |
93 |
--------------------------------------------------------------------------------
/client/src/components/bucket/bucket.css:
--------------------------------------------------------------------------------
1 | .bucket-container {
2 | align-items: center;
3 | display: flex;
4 | flex-direction: column;
5 | }
6 |
7 | .bucket {
8 | align-items: center;
9 | background-color: #7F8285;
10 | background-color: rgba(255, 255, 255, 0.3);
11 | border: 1px solid transparent;
12 | display: flex;
13 | flex-direction: column-reverse;
14 | margin: 0 4px;
15 | min-height: 105px;
16 | padding: 4px 0;
17 | width: 70px;
18 | }
19 |
20 | .bucket-playing {
21 | background-color: #888;
22 | background-color: rgba(255, 255, 255, 0.45);
23 | border: 1px solid #AAA;
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/components/bucket/index.jsx:
--------------------------------------------------------------------------------
1 | // BUCKET
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { DropTarget } from 'react-dnd';
7 | import ItemTypes from '../../dnd/item-types';
8 | import { deleteBucket } from '../../redux/actions/actions-sequence';
9 | import { selectNextId } from '../../redux/selectors';
10 | import './bucket.css';
11 |
12 | import NoteInBucket from '../note-in-bucket';
13 |
14 | const Bucket = ({ notes, currentNote, bucketId, trackId, nextId, connectDropTarget }) => {
15 |
16 | function renderNotes() {
17 | return notes.map((note, i) => {
18 | let active = currentNote[0] === bucketId && currentNote[1] === i;
19 |
20 | return (
21 |
31 | );
32 | });
33 | }
34 |
35 | let styleName = currentNote[0] === bucketId
36 | ? 'bucket bucket-playing'
37 | : 'bucket';
38 |
39 | return connectDropTarget(
40 |
41 | {renderNotes()}
42 |
43 | );
44 | };
45 |
46 | const bucketTarget = {
47 | drop(props, monitor) {
48 | if (monitor.didDrop())
49 | return;
50 | return {
51 | target: 'bucket',
52 | bucketId: props.bucketId,
53 | trackId: props.trackId,
54 | nextId: props.nextId,
55 | length: props.notes.length
56 | };
57 | }
58 | };
59 |
60 | function collect(connect) {
61 | return {
62 | connectDropTarget: connect.dropTarget()
63 | };
64 | }
65 |
66 | function mapStateToProps(state, ownProps) {
67 | return { nextId: selectNextId(ownProps.trackId)(state) };
68 | }
69 |
70 | function mapDispatchToProps(dispatch) {
71 | return bindActionCreators({ deleteBucket }, dispatch);
72 | }
73 |
74 | const dt_Bucket = DropTarget(ItemTypes.NOTE, bucketTarget, collect)(Bucket);
75 |
76 | export default connect(mapStateToProps, mapDispatchToProps)(dt_Bucket);
77 |
--------------------------------------------------------------------------------
/client/src/components/create-account-form/create-account-form.css:
--------------------------------------------------------------------------------
1 | .create-account-form {
2 | margin: 0 0 25px 0;
3 | }
4 |
5 | .create-account-message {
6 | font-size: 0.8rem;
7 | margin: 0 0 10px 0;
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/components/create-account-form/index.jsx:
--------------------------------------------------------------------------------
1 | // CREATE-ACCOUNT-FORM
2 |
3 | import React, { Component } from 'react';
4 | import './create-account-form.css';
5 |
6 | import InputWithMessage from '../input-with-message';
7 |
8 | class CreateAccountForm extends Component {
9 | constructor(props) {
10 | super(props);
11 | this.state = {
12 | email: '',
13 | password: '',
14 | confPassword: '',
15 | errors: {
16 | email: '',
17 | password: '',
18 | confPassword: ''
19 | },
20 | emailValid: false,
21 | passwordValid: false,
22 | confPasswordValid: false,
23 | formValid: false
24 | };
25 | this.handleFormSubmit = this.handleFormSubmit.bind(this);
26 | }
27 |
28 | componentDidMount() {
29 | this.emailInput.focus();
30 | }
31 |
32 | handleInputChange(field) {
33 | return event => {
34 | const { value } = event.target;
35 | this.setState({ [field]: value }, () => { this.validateInput(field, value); });
36 | };
37 | }
38 |
39 | handleFormSubmit(event) {
40 | event.preventDefault();
41 | const { email, password, confPassword } = this.state;
42 | this.props.onSubmit(email, password, confPassword);
43 | }
44 |
45 | validateInput(field, value) {
46 | const errors = { ...this.state.errors };
47 | let { emailValid, passwordValid, confPasswordValid } = this.state;
48 |
49 | switch(field) {
50 | case 'email':
51 | emailValid = value.match(/^([\w.%+-]+)@([\w-]+\.)+([\w]{2,})$/i);
52 | errors.email = emailValid
53 | ? ''
54 | : 'Invalid email address';
55 | break;
56 |
57 | case 'password':
58 | passwordValid = value.length >= 6;
59 | errors.password = passwordValid
60 | ? ''
61 | : 'Minimum 6 characters';
62 | confPasswordValid = value === this.state.confPassword;
63 | errors.confPassword = confPasswordValid
64 | ? ''
65 | : 'Passwords don\'t match';
66 | break;
67 |
68 | case 'confPassword':
69 | confPasswordValid = value === this.state.password;
70 | errors.confPassword = confPasswordValid
71 | ? ''
72 | : 'Passwords don\'t match';
73 | break;
74 |
75 | default:
76 | break;
77 | }
78 |
79 | this.setState({
80 | errors,
81 | emailValid,
82 | passwordValid,
83 | confPasswordValid,
84 | formValid: emailValid && passwordValid && confPasswordValid
85 | });
86 | }
87 |
88 | render() {
89 | const { email, password, confPassword, errors, formValid } = this.state;
90 | const { message, loading } = this.props;
91 | return (
92 |
93 |
{message && message}
94 |
132 |
133 |
134 | );
135 | }
136 | }
137 |
138 | export default CreateAccountForm;
139 |
--------------------------------------------------------------------------------
/client/src/components/delete-share-save/delete-share-save.css:
--------------------------------------------------------------------------------
1 | .delete-share-save {
2 | position: relative;
3 | }
4 |
5 | .project-button {
6 | font-size: 0.85rem;
7 | height: 25px;
8 | margin: 0 3px;
9 | padding: 2px;
10 | width: 70px;
11 | }
12 |
--------------------------------------------------------------------------------
/client/src/components/delete-share-save/index.jsx:
--------------------------------------------------------------------------------
1 | // DELETE-SHARE-SAVE
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { Link } from 'react-router-dom';
7 | import { selectCanSave, selectEmail } from '../../redux/selectors';
8 | import { deleteProject } from '../../redux/actions/actions-project';
9 | import { setUser, save } from '../../redux/actions/actions-user';
10 | import { resourceRequest } from '../../utils';
11 | import './delete-share-save.css';
12 |
13 | import Sharing from '../sharing';
14 |
15 | class DeleteShareSave extends React.Component {
16 | constructor(props) {
17 | super(props);
18 | this.state = { saving: false, showDropDown: false };
19 | this.handleDeleteClick = this.handleDeleteClick.bind(this);
20 | this.handleSharingClick = this.handleSharingClick.bind(this);
21 | this.handleSaveClick = this.handleSaveClick.bind(this);
22 | }
23 |
24 | handleSaveClick() {
25 | const refreshToken = localStorage.getItem('refreshToken');
26 | if (!refreshToken || !this.props.email) {
27 | this.props.setMessage('Please sign in to save your project');
28 | return;
29 | }
30 |
31 | const {
32 | bpm,
33 | name,
34 | tracks,
35 | shared,
36 | id,
37 | canSave,
38 | save,
39 | setMessage,
40 | setUser } = this.props;
41 |
42 | if (canSave) {
43 | this.setState({ saving: true });
44 | // if the project has no id, then we need a POST request to /save
45 | if (!id) {
46 | resourceRequest('post', 'save', {
47 | success: res => {
48 | save({ id: res.data.projectId, name });
49 | this.setState({ saving: false });
50 | },
51 | failure: err => {
52 | setMessage(err.response.data.error);
53 | this.setState({ saving: false });
54 | },
55 | authFailure: () => {
56 | setUser({ email: null, userId: null });
57 | this.setState({ saving: false });
58 | }
59 | },
60 | { bpm, name, tracks, shared });
61 | }
62 |
63 | // if the project has an id, we need a PUT request to /save
64 | else {
65 | resourceRequest('put', 'save', {
66 | success: () => {
67 | save({ id, name });
68 | this.setState({ saving: false });
69 | },
70 | failure: err => {
71 | setMessage(err.response.data.error);
72 | this.setState({ saving: false });
73 | },
74 | authFailure: () => {
75 | setUser({ email: null, userId: null });
76 | this.setState({ saving: false });
77 | }
78 | },
79 | { bpm, name, tracks, shared, id });
80 | }
81 | }
82 | }
83 |
84 | handleDeleteClick() {
85 | const { id, name, deleteProject, setMessage } = this.props;
86 |
87 | if (window.confirm(`Are you sure you want to delete ${name}?`)) {
88 | resourceRequest('delete', `project/${id}`, {
89 | success: () => {
90 | setMessage('Project deleted successfully');
91 | deleteProject(id);
92 | },
93 | failure: err => { setMessage(err.response.data.error); },
94 | authFailure: () => { setUser({ email: null, userId: null }); }
95 | });
96 | }
97 | }
98 |
99 | handleSharingClick() {
100 | this.setState(prevState => ({ showDropDown: !prevState.showDropDown }));
101 | }
102 |
103 | render() {
104 | const { canSave, email, id } = this.props;
105 | return (
106 |
107 | {email && id && [
108 | ,
115 |
122 | ]}
123 |
124 |
135 |
136 | {this.state.showDropDown &&
137 | this.setState({ showDropDown: false })}
140 | setMessage={this.props.setMessage}
141 | />
142 | }
143 |
144 | );
145 | }
146 | }
147 |
148 |
149 | function mapStateToProps(state) {
150 | return {
151 | canSave: selectCanSave(state),
152 | email: selectEmail(state)
153 | };
154 | }
155 |
156 | function mapDispatchToProps(dispatch) {
157 | return bindActionCreators({ deleteProject, save, setUser }, dispatch);
158 | }
159 |
160 | export default connect(mapStateToProps, mapDispatchToProps)(DeleteShareSave);
161 |
--------------------------------------------------------------------------------
/client/src/components/editable-text/editable-text.css:
--------------------------------------------------------------------------------
1 | .editable-text {
2 | background-color: inherit;
3 | color: inherit;
4 | font-size: inherit;
5 | height: 100%;
6 | padding: 2px 0;
7 | width: 100%;
8 | }
9 |
10 | .editable-text-div {
11 | border-bottom: 1px solid transparent;
12 | overflow: hidden;
13 | text-overflow: ellipsis;
14 | white-space: nowrap;
15 | }
16 |
17 | .editable-text-div:hover {
18 | border-bottom: 1px solid #AAA;
19 | }
20 |
21 | .editable-text-input {
22 | border: none;
23 | border-bottom: 1px solid transparent;
24 | }
25 |
26 | .editable-text-input:focus {
27 | border-bottom: 1px solid #AAA;
28 | outline: none;
29 | }
30 |
--------------------------------------------------------------------------------
/client/src/components/editable-text/index.jsx:
--------------------------------------------------------------------------------
1 | // EDITABLE-TEXT
2 |
3 | import React from 'react';
4 | import './editable-text.css';
5 |
6 | class EditableText extends React.Component {
7 | constructor(props) {
8 | super(props);
9 | this.state = { editing: false, value: props.value };
10 |
11 | this.handleInputChange = this.handleInputChange.bind(this);
12 | this.handleClick = this.handleClick.bind(this);
13 | this.handleBlur = this.handleBlur.bind(this);
14 | this.focusInput = this.focusInput.bind(this);
15 | this.handleSubmit = this.handleSubmit.bind(this);
16 | }
17 |
18 | componentDidUpdate(_, prevState) {
19 | // focus and highlight when clicked
20 | if (!prevState.editing && this.state.editing)
21 | this.focusInput();
22 | }
23 |
24 | componentWillReceiveProps(nextProps) {
25 | if (nextProps.value !== this.props.value)
26 | this.setState({ value: nextProps.value });
27 | }
28 |
29 | handleInputChange(event) {
30 | this.setState({ value: event.target.value });
31 | }
32 |
33 | handleClick() {
34 | this.setState({ editing: true });
35 | }
36 |
37 | handleBlur() {
38 | this.state.value !== ''
39 | ? this.props.onBlur(this.state.value)
40 | : this.setState({ value: this.props.value });
41 | this.setState({ editing: false });
42 | }
43 |
44 | focusInput() {
45 | this.textInput.focus();
46 | this.textInput.select();
47 | }
48 |
49 | handleSubmit(event) {
50 | event.preventDefault();
51 | this.handleBlur();
52 | }
53 |
54 | render() {
55 | const { title, value } = this.props;
56 | if (this.state.editing) {
57 | return (
58 |
70 | );
71 | }
72 | else {
73 | return (
74 |
75 | {value}
76 |
77 | );
78 | }
79 | }
80 | }
81 |
82 | export default EditableText;
83 |
--------------------------------------------------------------------------------
/client/src/components/envelope/envelope.css:
--------------------------------------------------------------------------------
1 | .envelope {
2 |
3 | }
4 |
--------------------------------------------------------------------------------
/client/src/components/envelope/index.jsx:
--------------------------------------------------------------------------------
1 | // ENVELOPE
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { selectEnvelope } from '../../redux/selectors';
7 | import {
8 | updateAttack,
9 | updateDecay,
10 | updateSustain,
11 | updateRelease } from '../../redux/actions/actions-synth';
12 | import './envelope.css';
13 |
14 | const EnvelopeSlider = ({ text, ...restProps }) => {
15 | return (
16 |
17 |
18 |
23 |
24 | );
25 | };
26 |
27 | const Envelope = ({
28 | id,
29 | envelope,
30 | updateAttack,
31 | updateDecay,
32 | updateSustain,
33 | updateRelease }) => {
34 |
35 | function handleAttackChange(event) {
36 | updateAttack({ value: Number(event.target.value), trackId: id });
37 | }
38 |
39 | function handleDecayChange(event) {
40 | updateDecay({ value: Number(event.target.value), trackId: id });
41 | }
42 |
43 | function handleSustainChange(event) {
44 | updateSustain({ value: Number(event.target.value), trackId: id });
45 | }
46 |
47 | function handleReleaseChange(event) {
48 | updateRelease({ value: Number(event.target.value), trackId: id });
49 | }
50 |
51 | return (
52 |
53 |
61 |
69 |
77 |
85 |
86 | );
87 | };
88 |
89 | function mapStateToProps(state, { id }) {
90 | return { envelope: selectEnvelope(id)(state) };
91 | }
92 |
93 | function mapDispatchToProps(dispatch) {
94 | const actions = {
95 | updateAttack,
96 | updateDecay,
97 | updateSustain,
98 | updateRelease,
99 | };
100 | return bindActionCreators(actions, dispatch);
101 | }
102 |
103 | export default connect(mapStateToProps, mapDispatchToProps)(Envelope);
104 |
--------------------------------------------------------------------------------
/client/src/components/filter/filter.css:
--------------------------------------------------------------------------------
1 | .filter {
2 | display: flex;
3 | flex-direction: column;
4 | }
5 |
6 | .filter-slider {
7 | margin: 0 10px 10px 0;
8 | }
9 |
10 | .filter-bottom {
11 | display: flex;
12 | }
13 |
14 | .filter-bottom-left {
15 | display: flex;
16 | flex-direction: column;
17 | margin: 0 15px 0 0;
18 | }
19 |
20 | .filter-type {
21 | border-radius: 0;
22 | margin: 5px 0 0 0;
23 | width: 100px;
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/components/filter/index.jsx:
--------------------------------------------------------------------------------
1 | // FILTER
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { selectFilter } from '../../redux/selectors';
7 | import {
8 | updateFilterFrequency,
9 | updateFilterType,
10 | updateFilterResonance } from '../../redux/actions/actions-track';
11 | import './filter.css';
12 |
13 | import LogarithmicSlider from '../logarithmic-slider';
14 |
15 | const Filter = ({
16 | id,
17 | filter,
18 | updateFilterType,
19 | updateFilterFrequency,
20 | updateFilterResonance }) => {
21 |
22 | function handleFrequencyChange(value) {
23 | updateFilterFrequency({ value, trackId: id });
24 | }
25 |
26 | function handleTypeChange(event) {
27 | updateFilterType({ type: event.target.value, trackId: id });
28 | }
29 |
30 | function handleResonanceChange(event) {
31 | updateFilterResonance({ value: Number(event.target.value), trackId: id });
32 | }
33 |
34 | return (
35 |
36 | Frequency:
37 |
49 |
50 |
51 | Type:
52 |
63 |
64 |
65 |
66 | Resonance:
67 |
76 |
77 |
78 |
79 |
80 | );
81 | };
82 |
83 | function mapStateToProps(state, ownProps) {
84 | return { filter: selectFilter(ownProps.id)(state)};
85 | }
86 |
87 | function mapDispatchToProps(dispatch) {
88 | const actions = { updateFilterType, updateFilterFrequency, updateFilterResonance };
89 | return bindActionCreators(actions, dispatch);
90 | }
91 |
92 | export default connect(mapStateToProps, mapDispatchToProps)(Filter);
93 |
--------------------------------------------------------------------------------
/client/src/components/header/header.css:
--------------------------------------------------------------------------------
1 | .header {
2 | align-items: center;
3 | background-color: rgb(0, 0, 0);
4 | background-color: rgba(0, 0, 0, 0.2);
5 | box-sizing: border-box;
6 | display: flex;
7 | flex: none;
8 | height: 50px;
9 | justify-content: space-between;
10 | padding: 0 15px;
11 | width: 100%;
12 | }
13 |
14 | .header-title {
15 | font-size: 1.2rem;
16 | }
17 |
18 | .header-link {
19 | text-decoration: none;
20 | }
21 |
22 | .header-button {
23 | height: 22px;
24 | margin: 0 5px;
25 | padding: 2px 7px;
26 | }
27 |
--------------------------------------------------------------------------------
/client/src/components/header/index.jsx:
--------------------------------------------------------------------------------
1 | // HEADER
2 |
3 | import React, { Component } from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { Link } from 'react-router-dom';
7 | import { selectEmail } from '../../redux/selectors';
8 | import { createNewProject } from '../../redux/actions/actions-project';
9 | import { WEB_BASE_URL } from '../../utils';
10 | import './header.css';
11 |
12 | import AccountDropdown from '../account-dropdown';
13 |
14 | class Header extends Component {
15 | constructor(props) {
16 | super(props);
17 | this.state = { showDropDown: false };
18 | this.toggleHideShow = this.toggleHideShow.bind(this);
19 | this.handleNewProjectClick = this.handleNewProjectClick.bind(this);
20 | }
21 |
22 | toggleHideShow() {
23 | this.setState(prevState => ({
24 | showDropDown: !prevState.showDropDown
25 | }));
26 | }
27 |
28 | handleNewProjectClick() {
29 | this.props.createNewProject();
30 | }
31 |
32 | render() {
33 | const { email } = this.props;
34 | return (
35 |
62 | );
63 | }
64 | }
65 |
66 | function mapStateToProps(state) {
67 | return { email: selectEmail(state) };
68 | }
69 |
70 | function mapDispatchToProps(dispatch) {
71 | return bindActionCreators({ createNewProject }, dispatch);
72 | }
73 |
74 | export default connect(mapStateToProps, mapDispatchToProps)(Header);
75 |
--------------------------------------------------------------------------------
/client/src/components/input-with-message/index.jsx:
--------------------------------------------------------------------------------
1 | // INPUT-WITH-MESSAGE
2 |
3 | import React from 'react';
4 | import './input-with-message.css';
5 |
6 | const InputWithMessage = ({ inputRef, message, ...restProps }) => {
7 |
8 | return (
9 |
17 | );
18 | };
19 |
20 | export default InputWithMessage;
21 |
--------------------------------------------------------------------------------
/client/src/components/input-with-message/input-with-message.css:
--------------------------------------------------------------------------------
1 | .input-with-message {
2 | display: flex;
3 | flex-direction: column;
4 | }
5 |
6 | .input-dark {
7 | background-color: rgb(0, 0, 0);
8 | background-color: rgba(0, 0, 0, 0.3);
9 | border: none;
10 | border-bottom: 1px solid transparent;
11 | color: #AAA;
12 | padding: 4px 5px;
13 | width: auto;
14 | }
15 |
16 | .input-dark:focus {
17 | border-bottom: 1px solid #AAA;
18 | outline: none;
19 | }
20 |
21 | .input-message {
22 | box-sizing: border-box;
23 | font-size: 0.8rem;
24 | height: 22px;
25 | padding: 3px 10px;
26 | }
27 |
--------------------------------------------------------------------------------
/client/src/components/keyboard/index.jsx:
--------------------------------------------------------------------------------
1 | // KEYBOARD
2 |
3 | import React from 'react';
4 | import './keyboard.css';
5 |
6 | import NoteInKeyboard from '../note-in-keyboard';
7 |
8 |
9 | const NaturalOctave = ({ octave }) => {
10 | const naturals = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
11 |
12 | return naturals.map((value, i) => {
13 | return ;
14 | });
15 | };
16 |
17 | const AccidentalOctave = ({ octave }) => {
18 |
19 | const accidentals2 = ['C#', 'D#'];
20 | const accidentals3 = ['F#', 'G#', 'A#'];
21 |
22 | function renderAccidentals(arr) {
23 | return (
24 |
25 | {arr.map((value, i) => )}
26 |
27 | );
28 | }
29 |
30 | return [
31 | renderAccidentals(accidentals2),
32 | renderAccidentals(accidentals3)
33 | ];
34 | };
35 |
36 | const Keyboard = () => {
37 |
38 | return (
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | );
58 | };
59 |
60 | export default Keyboard;
61 |
--------------------------------------------------------------------------------
/client/src/components/keyboard/keyboard.css:
--------------------------------------------------------------------------------
1 | .keyboard {
2 | overflow-y: auto;
3 | width: 100%;
4 | }
5 |
6 | .keyboard-notes {
7 | display: flex;
8 | flex: none;
9 | }
10 |
11 | .keyboard-col {
12 | display: flex;
13 | flex: none;
14 | flex-direction: column;
15 | margin: 0 1px;
16 | }
17 |
18 | /* holds 2 accidentals */
19 | .keyboard-acc2 {
20 | display: flex;
21 | flex-direction: column;
22 | height: 78px;
23 | justify-content: center;
24 | }
25 |
26 | /* holds 3 accidentals */
27 | .keyboard-acc3 {
28 | display: flex;
29 | flex-direction: column;
30 | height: 104px;
31 | justify-content: center;
32 | }
33 |
34 | .keyboard::-webkit-scrollbar {
35 | background-color: inherit;
36 | width: 10px;
37 | }
38 |
39 | .keyboard::-webkit-scrollbar-thumb {
40 | background-color: black;
41 | background-color: rgba(0, 0, 0, 0.3);
42 | border-radius: 5px;
43 | width: 5px;
44 | }
45 |
--------------------------------------------------------------------------------
/client/src/components/logarithmic-slider/index.jsx:
--------------------------------------------------------------------------------
1 | // LOGARITHMIC-SLIDER
2 |
3 | import React from 'react';
4 | import './logarithmic-slider.css';
5 |
6 | const LogarithmicSlider = ({
7 | name,
8 | value,
9 | minPosition,
10 | maxPosition,
11 | minValue,
12 | maxValue,
13 | label,
14 | onChange,
15 | className,
16 | inputClassName }) => {
17 |
18 | const minV = Math.log(minValue);
19 | const maxV = Math.log(maxValue);
20 | const scale = (maxV - minV) / (maxPosition - minPosition);
21 |
22 | function positionToValue(position) {
23 | return Math.exp(minV + scale * (position - minPosition));
24 | }
25 |
26 | function valueToPosition(value) {
27 | return (Math.log(value) - minV) / scale + minPosition;
28 | }
29 |
30 | function handleChange(event) {
31 | const value = positionToValue(Number(event.target.value));
32 | onChange(value);
33 | }
34 |
35 | return (
36 |
37 |
46 |
47 |
48 | );
49 | };
50 |
51 | export default LogarithmicSlider;
52 |
--------------------------------------------------------------------------------
/client/src/components/logarithmic-slider/logarithmic-slider.css:
--------------------------------------------------------------------------------
1 | .logarithmic-slider {
2 |
3 | }
4 |
--------------------------------------------------------------------------------
/client/src/components/note-column/index.jsx:
--------------------------------------------------------------------------------
1 | // NOTE-COLUMN
2 |
3 | import React from 'react';
4 | import './note-column.css';
5 |
6 | import Keyboard from '../keyboard';
7 | import NoteInKeyboard from '../note-in-keyboard';
8 |
9 | const NoteColumn = () => {
10 |
11 | return (
12 |
18 | );
19 | };
20 |
21 | export default NoteColumn;
22 |
--------------------------------------------------------------------------------
/client/src/components/note-column/note-column.css:
--------------------------------------------------------------------------------
1 | .note-column {
2 | border-left: 1px solid #222;
3 | box-sizing: border-box;
4 | display: flex;
5 | flex-direction: column;
6 | padding: 0 0 0 10px;
7 | width: 200px;
8 | }
9 |
10 | .rest-box {
11 | box-sizing: border-box;
12 | padding: 10px 0;
13 | }
14 |
--------------------------------------------------------------------------------
/client/src/components/note-in-bucket/index.jsx:
--------------------------------------------------------------------------------
1 | // NOTE-IN-BUCKET
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { DragSource, DropTarget } from 'react-dnd';
7 | import { findDOMNode } from 'react-dom';
8 | import flow from 'lodash/flow';
9 | import { deleteNote, addNote, moveNote } from '../../redux/actions/actions-sequence';
10 | import ItemTypes from '../../dnd/item-types';
11 |
12 | import './note-in-bucket.css';
13 |
14 | import Note from '../note';
15 |
16 | const NoteInBucket = ({
17 | value,
18 | active,
19 | connectDragSource,
20 | connectDropTarget,
21 | isDragging,
22 | isOver }) => {
23 |
24 | const opacity = isDragging ? 0 : 1;
25 |
26 | return connectDragSource(
27 | connectDropTarget(
28 |
29 |
30 |
31 | ));
32 | };
33 |
34 | const noteInBucketSource = {
35 | beginDrag(props) {
36 | return {
37 | id: props.id,
38 | noteIndex: props.index,
39 | bucketId: props.bucketId,
40 | trackId: props.trackId,
41 | value: props.value,
42 | source: 'bucket'
43 | };
44 | },
45 |
46 | isDragging(props, monitor) {
47 | const { id, trackId } = monitor.getItem();
48 | return props.id === id && props.trackId === trackId;
49 | },
50 |
51 | endDrag(props, monitor) {
52 | if (monitor.didDrop()) {
53 | const { target } = monitor.getDropResult();
54 |
55 | // if it's dropped in a deletion zone
56 | if (target === 'delete') {
57 | const { deleteNote } = props;
58 | const { bucketId, trackId, noteIndex } = monitor.getItem();
59 | deleteNote({ noteIndex, bucketId, trackId });
60 | }
61 |
62 | // if it's dropped in a bucket
63 | if (target === 'bucket') {
64 | // using monitor.getItem() for the source is more reliable than using props
65 | // because this note may have been dragged through intermediary buckets
66 | const {
67 | noteIndex: index,
68 | id,
69 | bucketId: bucket,
70 | value,
71 | trackId } = monitor.getItem();
72 |
73 | const payload = {
74 | source: {
75 | index,
76 | id,
77 | bucket,
78 | value,
79 | trackId
80 | },
81 | target: {
82 | index: monitor.getDropResult().length,
83 | bucket: monitor.getDropResult().bucketId,
84 | trackId: monitor.getDropResult().trackId
85 | }
86 | };
87 | props.moveNote(payload);
88 | }
89 | }
90 | }
91 | };
92 |
93 | const noteInBucketTarget = {
94 | hover(props, monitor, component) {
95 | const {
96 | noteIndex: dragNoteIndex,
97 | bucketId: dragBucketId,
98 | trackId: dragTrackId } = monitor.getItem();
99 |
100 | const {
101 | index: hoverNoteIndex,
102 | bucketId: hoverBucketId,
103 | trackId: hoverTrackId } = props;
104 |
105 | // if the dragSource note and the dropTarget note are in the same track, same bucket,
106 | // and same index, then we don't want to do anything because they're the same note
107 | if (
108 | dragNoteIndex === hoverNoteIndex &&
109 | dragBucketId === hoverBucketId &&
110 | dragTrackId === hoverTrackId) {
111 | return;
112 | }
113 |
114 | const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
115 | const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
116 | const clientOffset = monitor.getClientOffset();
117 | const hoverClientY = clientOffset.y - hoverBoundingRect.top;
118 |
119 | if (dragNoteIndex < hoverNoteIndex && hoverClientY < hoverMiddleY)
120 | return;
121 |
122 | if (dragNoteIndex > hoverNoteIndex && hoverClientY > hoverMiddleY)
123 | return;
124 |
125 | const item = monitor.getItem();
126 |
127 | // if it's a note from the keyboard
128 | if (item.source === 'keyboard') {
129 | props.addNote({
130 | value: item.value,
131 | id: props.nextId,
132 | index: hoverNoteIndex,
133 | bucketId: props.bucketId,
134 | trackId: props.trackId
135 | });
136 | monitor.getItem().value = item.value;
137 | monitor.getItem().source = null;
138 | monitor.getItem().noteIndex = props.index;
139 | monitor.getItem().bucketId = props.bucketId;
140 | monitor.getItem().id = props.nextId;
141 | monitor.getItem().trackId = props.trackId;
142 | return;
143 | }
144 |
145 | // otherwise, it's a note from a bucket
146 | const payload = {
147 | source: {
148 | index: dragNoteIndex,
149 | id: item.id,
150 | bucket: item.bucketId,
151 | value: item.value,
152 | trackId: item.trackId
153 | },
154 | target: {
155 | index: hoverNoteIndex,
156 | bucket: props.bucketId,
157 | trackId: props.trackId
158 | }
159 | };
160 |
161 | props.moveNote(payload);
162 |
163 | // if we're moving it into another track, then update the note's id & trackId
164 | if (item.trackId !== props.trackId) {
165 | monitor.getItem().id = props.nextId;
166 | monitor.getItem().trackId = props.trackId;
167 | }
168 |
169 | monitor.getItem().noteIndex = hoverNoteIndex;
170 | monitor.getItem().bucketId = props.bucketId;
171 | }
172 | };
173 |
174 | function sourceCollect(connect, monitor) {
175 | return {
176 | connectDragSource: connect.dragSource(),
177 | isDragging: monitor.isDragging()
178 | };
179 | }
180 |
181 | function targetCollect(connect) {
182 | return { connectDropTarget: connect.dropTarget() };
183 | }
184 |
185 | function mapDispatchToProps(dispatch) {
186 | return bindActionCreators({ deleteNote, addNote, moveNote }, dispatch);
187 | }
188 |
189 | const NoteInBucket_DTDS = flow([
190 | DragSource(ItemTypes.NOTE, noteInBucketSource, sourceCollect),
191 | DropTarget(ItemTypes.NOTE, noteInBucketTarget, targetCollect)
192 | ])(NoteInBucket);
193 |
194 | export default connect(null, mapDispatchToProps)(NoteInBucket_DTDS);
195 |
--------------------------------------------------------------------------------
/client/src/components/note-in-bucket/note-in-bucket.css:
--------------------------------------------------------------------------------
1 | .note-in-bucket {
2 | display: flex;
3 | flex-grow: 1;
4 | margin: 1px 0;
5 | }
--------------------------------------------------------------------------------
/client/src/components/note-in-keyboard/index.jsx:
--------------------------------------------------------------------------------
1 | // NOTE-IN-KEYBOARD
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { DragSource } from 'react-dnd';
7 | import { addNote, moveNote, deleteNote } from '../../redux/actions/actions-sequence';
8 | import { updateTestNote } from '../../redux/actions/actions-project';
9 | import { selectTestNote } from '../../redux/selectors';
10 | import ItemTypes from '../../dnd/item-types';
11 | import './note-in-keyboard.css';
12 |
13 | import Note from '../note';
14 |
15 | const NoteInKeyboard = ({
16 | value,
17 | styleName,
18 | updateTestNote,
19 | connectDragSource }) => {
20 |
21 | function testNoteOn() {
22 | if (value !== 'rest')
23 | updateTestNote({ on: true, value });
24 | }
25 |
26 | function testNoteOff() {
27 | if (value !== 'rest')
28 | updateTestNote({ on: false, value });
29 | }
30 |
31 | return connectDragSource(
32 |
38 |
39 |
40 | );
41 | };
42 |
43 | // drag source spec function
44 | const noteInKeyboardSource = {
45 | beginDrag(props) {
46 | return {
47 | value: props.value,
48 | source: 'keyboard'
49 | };
50 | },
51 |
52 | endDrag(props, monitor) {
53 | if (monitor.didDrop()) {
54 | const { value, addNote, moveNote, deleteNote } = props;
55 | const { target, bucketId, trackId, nextId, length } = monitor.getDropResult();
56 |
57 | // if it's dropped in a bucket
58 | if (target === 'bucket') {
59 | const item = monitor.getItem();
60 |
61 | // if it's being dragged directly from the keyboard, drop it in the bucket
62 | if (item.source === 'keyboard') {
63 | addNote({
64 | value,
65 | bucketId,
66 | trackId,
67 | index: length,
68 | id: nextId
69 | });
70 | }
71 |
72 | // but if it's been hovering in another bucket, then dragged here,
73 | // move it from one bucket to the other
74 | else {
75 | const payload = {
76 | source: {
77 | index: item.noteIndex,
78 | id: item.id,
79 | bucket: item.bucketId,
80 | value: item.value,
81 | trackId: item.trackId
82 | },
83 | target: {
84 | index: length,
85 | bucket: bucketId,
86 | trackId: trackId
87 | }
88 | };
89 | moveNote(payload);
90 | }
91 | }
92 | // if it's dragged into a bucket, but then pulled out to be deleted
93 | else if (target === 'delete' && monitor.getItem().source !== 'keyboard') {
94 | const { noteIndex, bucketId, trackId } = monitor.getItem();
95 | deleteNote({ noteIndex, bucketId, trackId });
96 | }
97 | }
98 | }
99 | };
100 |
101 | function collect(connect) {
102 | return {
103 | connectDragSource: connect.dragSource()
104 | };
105 | }
106 |
107 | function mapStateToProps(state) {
108 | return { testNote: selectTestNote(state) };
109 | }
110 |
111 | function mapDispatchToProps(dispatch) {
112 | const actions = {
113 | addNote,
114 | deleteNote,
115 | moveNote,
116 | updateTestNote
117 | };
118 | return bindActionCreators(actions, dispatch);
119 | }
120 |
121 | const NoteInKeyboard_DS = DragSource(
122 | ItemTypes.NOTE,
123 | noteInKeyboardSource,
124 | collect
125 | )(NoteInKeyboard);
126 |
127 | export default connect(mapStateToProps, mapDispatchToProps)(NoteInKeyboard_DS);
128 |
--------------------------------------------------------------------------------
/client/src/components/note-in-keyboard/note-in-keyboard.css:
--------------------------------------------------------------------------------
1 | .note-in-keyboard {
2 | display: flex;
3 | height: 24px;
4 | margin: 1px 0;
5 | width: 60px;
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/components/note/index.jsx:
--------------------------------------------------------------------------------
1 | // NOTE
2 |
3 | import React from 'react';
4 | import './note.css';
5 |
6 | function intFromNote(note) {
7 | let output = note.charCodeAt();
8 | if (note === 'A' || note === 'B')
9 | output += 7;
10 | output -= 66;
11 | return output;
12 | }
13 |
14 | function generateColor(noteValue) {
15 | if (noteValue === 'rest')
16 | return '#4C9393';
17 |
18 | const octaveColors = {
19 | 2: [50, 60, 120],
20 | 3: [70, 50, 100],
21 | 4: [100, 40, 70],
22 | 5: [120, 30, 10],
23 | 6: [170, 50, 0]
24 | };
25 |
26 | const note = intFromNote(noteValue[0]);
27 | const octave = noteValue[noteValue.length - 1];
28 | const color = octaveColors[octave].map(colorValue => colorValue + (note * 10));
29 |
30 | return `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
31 | }
32 |
33 | function renderNoteValue(noteValue) {
34 | if (noteValue.length === 3)
35 | return {noteValue[0]}♯{noteValue[2]};
36 | return {noteValue};
37 | }
38 |
39 | const Note = ({ value, active }) => {
40 |
41 | let color = '#F4A53F';
42 |
43 | if (!active)
44 | color = generateColor(value);
45 |
46 | return (
47 |
48 | {renderNoteValue(value)}
49 |
50 | );
51 | };
52 |
53 |
54 | export default Note;
55 |
--------------------------------------------------------------------------------
/client/src/components/note/note.css:
--------------------------------------------------------------------------------
1 | .note {
2 | align-items: center;
3 | color: #EEE;
4 | display: flex;
5 | font-size: 0.8rem;
6 | font-weight: 400;
7 | padding: 0 0 0 7px;
8 | width: 53px;
9 | }
10 |
--------------------------------------------------------------------------------
/client/src/components/oscillator/index.jsx:
--------------------------------------------------------------------------------
1 | // OSCILLATOR
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { selectOscillator } from '../../redux/selectors';
7 | import {
8 | updateOscillatorType,
9 | updateOscillatorDetune } from '../../redux/actions/actions-synth';
10 |
11 | import './oscillator.css';
12 |
13 | import SawtoothSVG from '../svg/sawtooth-svg';
14 | import SineSVG from '../svg/sine-svg';
15 | import TriangleSVG from '../svg/triangle-svg';
16 | import SquareSVG from '../svg/square-svg';
17 |
18 | const Oscillator = ({ id, oscillator, updateOscillatorType, updateOscillatorDetune }) => {
19 |
20 | const { type, detune } = oscillator;
21 |
22 | function handleOscTypeClick(type) {
23 | updateOscillatorType({ type, trackId: id });
24 | }
25 |
26 | function handleOscDetuneChange(event) {
27 | updateOscillatorDetune({ value: Number(event.target.value), trackId: id});
28 | }
29 |
30 | return (
31 |
32 |
33 | Wave Shape:
34 |
35 |
42 |
43 |
50 |
51 |
58 |
65 |
66 |
67 |
68 |
69 | Detune:
70 |
71 |
79 | {detune}
80 |
81 |
82 |
83 | );
84 | };
85 |
86 |
87 | function mapStateToProps(state, ownProps) {
88 | return { oscillator: selectOscillator(ownProps.id)(state)};
89 | }
90 |
91 | function mapDispatchToProps(dispatch) {
92 | return bindActionCreators({ updateOscillatorType, updateOscillatorDetune }, dispatch);
93 | }
94 |
95 | export default connect(mapStateToProps, mapDispatchToProps)(Oscillator);
96 |
--------------------------------------------------------------------------------
/client/src/components/oscillator/oscillator.css:
--------------------------------------------------------------------------------
1 | .oscillator {
2 |
3 | }
4 |
5 | .oscillator-type {
6 | margin: 0 0 15px 0;
7 | }
8 |
9 | .oscillator-type-buttons {
10 | display: flex;
11 | margin: 5px 0 0 0;
12 | }
13 |
14 | .oscillator-type-button {
15 | align-items: center;
16 | display: flex;
17 | justify-content: center;
18 | margin: 0 5px 0 0;
19 | }
20 |
21 | .oscillator-type-button:disabled {
22 | background-color: #FFF;
23 | background-color: rgba(255, 255, 255, 0.8);
24 | }
25 |
26 | .oscillator-type-wave-svg {
27 | height: 21px;
28 | width: 28px;
29 | }
30 |
31 | .oscillator-detune {
32 | display: flex;
33 | flex-direction: column;
34 | }
35 |
36 | .oscillator-detune-slider {
37 | align-items: center;
38 | display: flex;
39 | }
40 |
41 | .oscillator-detune-slider-input {
42 | margin: 0 10px 0 0;
43 | }
44 |
--------------------------------------------------------------------------------
/client/src/components/play-bpm/index.jsx:
--------------------------------------------------------------------------------
1 | // PLAY-BPM
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { play, stop, changeBPM } from '../../redux/actions/actions-project';
7 | import { selectPlaying, selectBPM } from '../../redux/selectors';
8 | import './play-bpm.css';
9 |
10 | const PlayBPM = ({ playing, bpm, play, stop, changeBPM }) => {
11 |
12 | function handlePlayStopClick() {
13 | playing
14 | ? stop()
15 | : play();
16 | }
17 |
18 | function handleBPMChange(event) {
19 | let newBPM = Number(event.target.value);
20 | changeBPM({ bpm: newBPM });
21 | }
22 |
23 | function renderPlayStop() {
24 | const className = playing ? 'stop' : 'play';
25 | return (
26 |
33 | );
34 | }
35 |
36 | return (
37 |
38 | {renderPlayStop()}
39 |
49 |
50 |
51 | );
52 | };
53 |
54 |
55 | function mapStateToProps(state) {
56 | return { playing: selectPlaying(state), bpm: selectBPM(state) };
57 | }
58 |
59 | function mapDispatchToProps(dispatch) {
60 | return bindActionCreators({ play, stop, changeBPM }, dispatch);
61 | }
62 |
63 | export default connect(mapStateToProps, mapDispatchToProps)(PlayBPM);
64 |
--------------------------------------------------------------------------------
/client/src/components/play-bpm/play-bpm.css:
--------------------------------------------------------------------------------
1 | .play-bpm {
2 | align-items: center;
3 | align-self: flex-start;
4 | display: flex;
5 | }
6 |
7 | .bpm-input {
8 | font-size: 0.9rem;
9 | height: 15px;
10 | margin: 0 5px;
11 | padding: 5px 0 5px 15px;
12 | width: 40px;
13 | }
14 |
15 | .bpm-label {
16 | font-size: 0.9rem;
17 | }
18 |
19 | .playstop-button {
20 | height: 25px;
21 | margin: 0 10px 0 0;
22 | width: 25px;
23 | }
24 |
25 | .play {
26 | border-bottom: 6px solid transparent;
27 | border-left: 10px solid white;
28 | border-top: 6px solid transparent;
29 | height: 0;
30 | width: 0;
31 | }
32 |
33 | .stop {
34 | background-color: white;
35 | height: 10px;
36 | width: 10px;
37 | }
38 |
--------------------------------------------------------------------------------
/client/src/components/project-buttons/index.jsx:
--------------------------------------------------------------------------------
1 | // PROJECT-BUTTONS
2 |
3 | import React from 'react';
4 | import './project-buttons.css';
5 |
6 | import PlayBPM from '../play-bpm';
7 | import DeleteShareSave from '../delete-share-save';
8 |
9 | class ProjectButtons extends React.Component {
10 | constructor(props) {
11 | super(props);
12 | this.state = { message: '' };
13 | this.hideMessage = this.hideMessage.bind(this);
14 | }
15 |
16 | componentDidUpdate() {
17 | this.state.message &&
18 | setTimeout(() => { this.hideMessage(); }, 3000);
19 | }
20 |
21 | hideMessage() {
22 | this.setState({ message: '' });
23 | }
24 |
25 | render() {
26 | const { message } = this.state;
27 | return (
28 |
29 |
30 |
31 |
this.setState({ message })}
34 | />
35 |
36 |
37 | {message &&
38 |
39 | {message}
40 |
47 |
48 | }
49 |
50 |
51 | );
52 | }
53 | }
54 |
55 | export default ProjectButtons;
56 |
--------------------------------------------------------------------------------
/client/src/components/project-buttons/project-buttons.css:
--------------------------------------------------------------------------------
1 | .project-buttons {
2 | display: flex;
3 | flex-direction: column;
4 | height: 100%;
5 | width: 55%;
6 | }
7 |
8 | .project-buttons-buttons {
9 | display: flex;
10 | height: 50%;
11 | justify-content: space-between;
12 | margin: 3px 0 0 0;
13 | }
14 |
15 | .project-buttons-message {
16 | display: flex;
17 | font-size: 0.8rem;
18 | height: 50%;
19 | justify-content: flex-end;
20 | }
21 |
22 | .close-message {
23 | margin: 0 0 0 5px;
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/components/project/index.jsx:
--------------------------------------------------------------------------------
1 | // PROJECT
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { DropTarget } from 'react-dnd';
7 | import { changeProjectName } from '../../redux/actions/actions-project';
8 | import { selectProject } from '../../redux/selectors';
9 | import ItemTypes from '../../dnd/item-types';
10 |
11 | import './project.css';
12 |
13 | import Tracks from '../tracks';
14 | import EditableText from '../editable-text';
15 | import ProjectButtons from '../project-buttons';
16 |
17 | const Project = props => {
18 | return props.connectDropTarget(
19 |
20 |
21 |
22 | { props.changeProjectName({ name: value }); }}
26 | />
27 |
28 |
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | const projectTarget = {
36 | drop(_, monitor) {
37 | // if it's been dropped on a child target, don't do anything
38 | if (monitor.didDrop())
39 | return;
40 | return { target: 'delete' };
41 | }
42 | };
43 |
44 | function collect(connect) {
45 | return {
46 | connectDropTarget: connect.dropTarget()
47 | };
48 | }
49 |
50 | function mapStateToProps(state) {
51 | return { ...selectProject(state) };
52 | }
53 |
54 | function mapDispatchToProps(dispatch) {
55 | return bindActionCreators({ changeProjectName }, dispatch);
56 | }
57 |
58 | const dt_Project = DropTarget(ItemTypes.NOTE, projectTarget, collect)(Project);
59 |
60 | export default connect(mapStateToProps, mapDispatchToProps)(dt_Project);
61 |
--------------------------------------------------------------------------------
/client/src/components/project/project.css:
--------------------------------------------------------------------------------
1 | .project {
2 | display: flex;
3 | flex-direction: column;
4 | overflow: auto;
5 | width: 100%;
6 | }
7 |
8 | .project-header {
9 | align-items: center;
10 | box-sizing: border-box;
11 | display: flex;
12 | height: 70px;
13 | justify-content: space-between;
14 | padding: 0 10px;
15 | width: 100%;
16 | }
17 |
18 | .project-title {
19 | background-color: inherit;
20 | color: inherit;
21 | font-size: 1.4rem;
22 | margin: 0 10px;
23 | width: 40%;
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/components/rc-footer/index.jsx:
--------------------------------------------------------------------------------
1 | // RC-FOOTER
2 |
3 | import React from 'react';
4 | import './rc-footer.css';
5 |
6 | const RCFooter = () => {
7 |
8 | return (
9 |
27 | );
28 | };
29 |
30 | export default RCFooter;
31 |
--------------------------------------------------------------------------------
/client/src/components/rc-footer/rc-footer.css:
--------------------------------------------------------------------------------
1 | .rc-footer {
2 | background-color: black;
3 | background-color: rgba(0, 0, 0, 0.2);
4 | bottom: 0;
5 | font-family: monospace;
6 | padding: 3px 15px;
7 | position: fixed;
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/components/sharing/index.jsx:
--------------------------------------------------------------------------------
1 | // SHARING
2 |
3 | import React, { Component } from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { share, unshare } from '../../redux/actions/actions-project';
7 | import { WEB_BASE_URL, resourceRequest } from '../../utils';
8 | import './sharing.css';
9 |
10 | class Sharing extends Component {
11 | constructor(props) {
12 | super(props);
13 | this.state = { loading: !props.shared };
14 | }
15 |
16 | componentDidMount() {
17 | !this.props.shared &&
18 | this.updateSharedStatus(true);
19 | }
20 |
21 | updateSharedStatus(shared) {
22 | const { bpm, name, id, tracks, hideDropDown, setMessage, setUser } = this.props;
23 |
24 | resourceRequest('put', 'save', {
25 | success: () => {
26 | shared
27 | ? this.handleShareSuccess()
28 | : this.handleUnshareSuccess();
29 | },
30 | failure: err => {
31 | hideDropDown();
32 | setMessage(err.response.data);
33 | },
34 | authFailure: () => {
35 | hideDropDown();
36 | setUser({ email: null, userId: null });
37 | }
38 | },
39 | { bpm, name, tracks, shared, id });
40 | }
41 |
42 | handleShareSuccess() {
43 | this.setState({ loading: false });
44 | this.props.share();
45 | }
46 |
47 | handleUnshareSuccess() {
48 | this.props.hideDropDown();
49 | this.props.setMessage('This project is no longer shared.');
50 | this.props.unshare();
51 | }
52 |
53 |
54 | render() {
55 | const { shared, id } = this.props;
56 | const shareURL = `${WEB_BASE_URL}share/${id}`;
57 |
58 | if (this.state.loading) {
59 | return (
60 |
61 | Getting shared link...
62 |
63 | );
64 | }
65 |
66 | return (
67 |
68 | {shared &&
69 |
70 |
73 | this.urlInput.select()}
77 | value={shareURL}
78 | readOnly
79 | ref={input => { this.urlInput = input; }}
80 | />
81 |
82 | }
83 |
89 |
90 | );
91 | }
92 | }
93 |
94 | function mapDispatchToProps(dispatch) {
95 | return bindActionCreators({ share, unshare }, dispatch);
96 | }
97 |
98 | export default connect(null, mapDispatchToProps)(Sharing);
99 |
--------------------------------------------------------------------------------
/client/src/components/sharing/sharing.css:
--------------------------------------------------------------------------------
1 | .sharing {
2 | background-color: #22262A;
3 | display: flex;
4 | flex-direction: column;
5 | height: 100px;
6 | justify-content: space-between;
7 | padding: 3%;
8 | position: absolute;
9 | top: 30px;
10 | width: 94%;
11 | z-index: 1000;
12 | }
13 |
14 | .sharing-url {
15 | display: flex;
16 | flex-direction: column;
17 | }
18 |
19 | .sharing-url-item {
20 | margin: 5px 0;
21 | }
22 |
23 | .sharing-url-input {
24 | font-size: 0.9rem;
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/components/signin-form/index.jsx:
--------------------------------------------------------------------------------
1 | // SIGNIN-FORM
2 |
3 | import React, { Component } from 'react';
4 | import './signin-form.css';
5 |
6 | import InputWithMessage from '../input-with-message';
7 |
8 | class SigninForm extends Component {
9 | constructor(props) {
10 | super(props);
11 | this.state = {
12 | email: '',
13 | password: '',
14 | formValid: false
15 | };
16 | this.onFormSubmit = this.onFormSubmit.bind(this);
17 | }
18 |
19 | componentDidMount() {
20 | this.emailEl.focus();
21 | }
22 |
23 | handleInputChange(field) {
24 | return event => {
25 | const { value } = event.target;
26 | this.setState({ [field]: value }, this.validateForm);
27 | };
28 | }
29 |
30 | onFormSubmit(event) {
31 | event.preventDefault();
32 | this.props.onSubmit(this.state.email, this.state.password);
33 | }
34 |
35 | validateForm() {
36 | this.setState({ formValid: this.state.email && this.state.password });
37 | }
38 |
39 | render() {
40 | const { message, onCreateClick, loading } = this.props;
41 | const { formValid } = this.state;
42 | return (
43 |
44 |
{message && message}
45 |
66 |
67 |
68 | );
69 | }
70 | }
71 |
72 | export default SigninForm;
73 |
--------------------------------------------------------------------------------
/client/src/components/signin-form/signin-form.css:
--------------------------------------------------------------------------------
1 | /*.signin-form {
2 |
3 | }*/
4 |
5 | .signin-message {
6 | font-size: 0.8rem;
7 | margin: 0 0 10px 0;
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/components/svg/blank-square.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // adapted from: https://feathericons.com/
4 | export default ({ className }) => (
5 |
18 | );
19 |
--------------------------------------------------------------------------------
/client/src/components/svg/clipboard.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // source: https://feathericons.com/
4 | export default ({ className }) => (
5 |
20 | );
21 |
--------------------------------------------------------------------------------
/client/src/components/svg/copy.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // source: https://feathericons.com/
4 | export default ({ className }) => (
5 |
20 | );
21 |
--------------------------------------------------------------------------------
/client/src/components/svg/sawtooth-svg.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // adapted from: https://commons.wikimedia.org
4 | export default ({ className }) => (
5 |
21 | );
22 |
--------------------------------------------------------------------------------
/client/src/components/svg/sine-svg.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // adapted from: https://commons.wikimedia.org
4 | export default ({ className }) => (
5 |
20 | );
21 |
--------------------------------------------------------------------------------
/client/src/components/svg/square-svg.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // adapted from: https://commons.wikimedia.org
4 | export default ({ className }) => (
5 |
21 | );
22 |
--------------------------------------------------------------------------------
/client/src/components/svg/three-dots-svg.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // source: https://feathericons.com/
4 | export default ({ className }) => (
5 |
21 | );
22 |
--------------------------------------------------------------------------------
/client/src/components/svg/triangle-svg.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // adapted from: https://commons.wikimedia.org
4 | export default ({ className }) => (
5 |
21 | );
22 |
--------------------------------------------------------------------------------
/client/src/components/svg/x-square.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | // adapted from: https://feathericons.com/
4 | export default ({ className }) => (
5 |
20 | );
21 |
--------------------------------------------------------------------------------
/client/src/components/track-info/index.jsx:
--------------------------------------------------------------------------------
1 | // TRACK-INFO
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import {
7 | mute,
8 | solo,
9 | unmute,
10 | unsolo,
11 | } from '../../redux/actions/actions-track';
12 | import { deleteTrack } from '../../redux/actions/actions-tracks';
13 | import { changeTrackName, updateTrackVolume } from '../../redux/actions/actions-track';
14 | import { selectTrack } from '../../redux/selectors';
15 | import './track-info.css';
16 |
17 | import EditableText from '../editable-text';
18 | import ThreeDotsSVG from '../svg/three-dots-svg';
19 | import TrackOptions from '../track-options';
20 |
21 | class TrackInfo extends React.Component {
22 | constructor(props) {
23 | super(props);
24 | this.state = { hover: false, showOptions: false };
25 | this.handleMuteClick = this.handleMuteClick.bind(this);
26 | this.handleSoloClick = this.handleSoloClick.bind(this);
27 | this.handleDeleteTrackClick = this.handleDeleteTrackClick.bind(this);
28 | this.handleVolumeChange = this.handleVolumeChange.bind(this);
29 | this.handleOptionsClick = this.handleOptionsClick.bind(this);
30 | }
31 |
32 | handleMuteClick() {
33 | const { id, mute, unmute, muted } = this.props;
34 | muted
35 | ? unmute(id)
36 | : mute(id);
37 | }
38 |
39 | handleSoloClick() {
40 | const { id, solo, unsolo, soloed } = this.props;
41 | soloed
42 | ? unsolo(id)
43 | : solo(id);
44 | }
45 |
46 | handleDeleteTrackClick() {
47 | const { id, deleteTrack } = this.props;
48 | deleteTrack({ trackId: id });
49 | }
50 |
51 | handleVolumeChange(event) {
52 | const { muted, id, updateTrackVolume } = this.props;
53 | if (!muted) {
54 | updateTrackVolume({
55 | trackId: id,
56 | volume: Number(event.target.value)
57 | });
58 | }
59 | }
60 |
61 | handleOptionsClick() {
62 | this.setState(prevState => ({ showOptions: !prevState.showOptions }));
63 | }
64 |
65 | renderMuteSolo() {
66 | const { muted, soloed } = this.props;
67 | const { showOptions } = this.state;
68 |
69 | const styleName = 'button-dark track-info-mutesolo';
70 |
71 | let muteStyle = muted
72 | ? styleName + ' track-info-mutesolo-active'
73 | : styleName;
74 |
75 | let soloStyle = soloed
76 | ? styleName + ' track-info-mutesolo-active'
77 | : styleName;
78 |
79 | let optionsStyle = showOptions
80 | ? 'button-dark track-info-mutesolo track-info-mutesolo-active'
81 | : 'button-dark track-info-mutesolo';
82 |
83 | return (
84 |
85 |
92 |
99 |
106 |
107 | );
108 | }
109 |
110 | render() {
111 | const { name, volume, id, changeTrackName } = this.props;
112 | const { hover, showOptions } = this.state;
113 | return (
114 | { this.setState({ hover: true }); }}
116 | onMouseLeave={() => { this.setState({ hover: false }); }}
117 | >
118 |
119 | {hover &&
120 |
127 | }
128 |
129 |
130 |
131 | { changeTrackName({ trackId: id, name: value }); }}
135 | />
136 |
137 | {this.renderMuteSolo()}
138 |
139 |
148 |
149 |
150 | {showOptions &&
151 |
152 | }
153 |
154 | );
155 | }
156 | }
157 |
158 | function mapStateToProps(state, ownProps) {
159 | return selectTrack(ownProps.id)(state);
160 | }
161 |
162 | function mapDispatchToProps(dispatch) {
163 | const actions = {
164 | mute,
165 | solo,
166 | unmute,
167 | unsolo,
168 | deleteTrack,
169 | changeTrackName,
170 | updateTrackVolume
171 | };
172 | return bindActionCreators(actions, dispatch);
173 | }
174 |
175 | export default connect(mapStateToProps, mapDispatchToProps)(TrackInfo);
176 |
--------------------------------------------------------------------------------
/client/src/components/track-info/track-info.css:
--------------------------------------------------------------------------------
1 | .track-info {
2 | align-items: center;
3 | display: flex;
4 | height: 100%;
5 | position: relative;
6 | width: 150px;
7 | }
8 |
9 | .track-info-left {
10 | margin: 0 5px 0 0;
11 | width: 25px;
12 | }
13 |
14 | .track-info-delete {
15 | font-size: 0.8rem;
16 | height: 20px;
17 | margin: 0;
18 | width: 20px;
19 | }
20 |
21 | .track-info-right {
22 | display: flex;
23 | flex-direction: column;
24 | width: 125px;
25 | }
26 |
27 | .track-info-text {
28 | margin: 2px;
29 | width: 100%;
30 | }
31 |
32 | .track-info-buttons {
33 | display: flex;
34 | }
35 |
36 | .track-info-mutesolo {
37 | align-items: center;
38 | display: flex;
39 | height: 25px;
40 | justify-content: center;
41 | margin: 10px 3px;
42 | padding: 1px 0 2px 0;
43 | width: 25px;
44 | }
45 |
46 | .track-info-mutesolo-active {
47 | background-color: #F4A53F;
48 | color: #222;
49 | }
50 |
51 | .track-info-more {
52 | width: 80%;
53 | }
54 |
55 | .hidden {
56 | display: none;
57 | }
58 |
59 | .volume {
60 | display: flex;
61 | }
62 |
--------------------------------------------------------------------------------
/client/src/components/track-options/index.jsx:
--------------------------------------------------------------------------------
1 | // TRACK-OPTIONS
2 |
3 | import React, { Component } from 'react';
4 | import './track-options.css';
5 |
6 | import Oscillator from '../oscillator';
7 | import Filter from '../filter';
8 | import Envelope from '../envelope';
9 |
10 | export class TrackOptions extends Component {
11 | constructor(props) {
12 | super(props);
13 | this.state = { mode: 'osc' };
14 | this.buttonClass = 'track-options-sidebar-button';
15 | this.activeButtonClass = this.buttonClass + ' ' + this.buttonClass + '-active';
16 | this.contentMap = {
17 | osc: ,
18 | filter: ,
19 | env:
20 | };
21 | }
22 |
23 | setMode(mode) {
24 | this.setState({ mode: mode });
25 | }
26 |
27 | render() {
28 | const { mode } = this.state;
29 | return (
30 |
31 |
32 |
38 |
44 |
50 |
51 |
52 |
53 | {this.contentMap[mode]}
54 |
55 |
56 |
57 | );
58 | }
59 | }
60 |
61 | export default TrackOptions;
62 |
--------------------------------------------------------------------------------
/client/src/components/track-options/track-options.css:
--------------------------------------------------------------------------------
1 | .track-options {
2 | background-color: #141619;
3 | background-color: rgba(0, 0, 0, 0.75);
4 | box-sizing: border-box;
5 | display: flex;
6 | height: 130px;
7 | left: 160px;
8 | padding: 8px;
9 | position: absolute;
10 | top: 0;
11 | width: 400px;
12 | z-index: 1000;
13 | }
14 |
15 | .track-options-sidebar {
16 | align-items: flex-start;
17 | border-right: 1px solid #555;
18 | display: flex;
19 | flex-direction: column;
20 | padding: 0 8px 0 0;
21 | }
22 |
23 | .track-options-sidebar-button {
24 | background-color: inherit;
25 | border: none;
26 | border-bottom: 1px solid transparent;
27 | color: #DDD;
28 | margin: 0 0 5px 0;
29 | text-align: left;
30 | width: 100%;
31 | }
32 |
33 | .track-options-sidebar-button:focus {
34 | border-bottom: 1px solid #555;
35 | outline: none;
36 | }
37 |
38 | .track-options-sidebar-button-active {
39 | background-color: #FFF;
40 | background-color: rgba(255, 255, 255, 0.8);
41 | color: #111;
42 | }
43 |
44 | .track-options-content {
45 | margin: 0 0 0 8px;
46 | }
47 |
48 |
49 | .track-options-slider {
50 | margin: 0 0 7px 0;
51 | }
52 |
53 | .track-options-slider-label {
54 | display: inline-block;
55 | width: 70px;
56 | }
57 |
58 | .track-options-slider-range {
59 | background-color: #555;
60 | width: 150px;
61 | }
62 |
63 | .track-options-slider-range:focus {
64 | background-color: #333;
65 | }
66 |
--------------------------------------------------------------------------------
/client/src/components/track/index.jsx:
--------------------------------------------------------------------------------
1 | // TRACK
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { selectTrack } from '../../redux/selectors';
6 |
7 | import './track.css';
8 |
9 | import TrackInfo from '../track-info';
10 | import BucketRow from '../bucket-row';
11 |
12 | const Track = ({ currentNote, sequence, id }) => {
13 |
14 | return (
15 |
16 |
17 |
22 |
23 | );
24 | };
25 |
26 | function mapStateToProps(state, ownProps) {
27 | return selectTrack(ownProps.id)(state);
28 | }
29 |
30 | export default connect(mapStateToProps)(Track);
31 |
--------------------------------------------------------------------------------
/client/src/components/track/track.css:
--------------------------------------------------------------------------------
1 | .track {
2 | box-sizing: border-box;
3 | display: flex;
4 | margin: 10px;
5 | }
6 |
--------------------------------------------------------------------------------
/client/src/components/tracks/index.jsx:
--------------------------------------------------------------------------------
1 | // TRACKS
2 |
3 | import React from 'react';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { addTrack } from '../../redux/actions/actions-tracks';
7 | import { selectTracks } from '../../redux/selectors';
8 | import './tracks.css';
9 |
10 | import Track from '../track';
11 |
12 | const Tracks = ({ tracks, addTrack }) => {
13 |
14 | function handleNewTrackClick() {
15 | addTrack();
16 | }
17 |
18 | function renderTracks() {
19 | return Object.values(tracks).map(track => {
20 | return ;
21 | });
22 | }
23 |
24 | return (
25 |
26 | {renderTracks()}
27 |
34 |
35 | );
36 | };
37 |
38 |
39 | function mapStateToProps(state) {
40 | return { tracks: selectTracks(state) };
41 | }
42 |
43 | function mapDispatchToProps(dispatch) {
44 | return bindActionCreators({ addTrack }, dispatch);
45 | }
46 |
47 | export default connect(mapStateToProps, mapDispatchToProps)(Tracks);
48 |
--------------------------------------------------------------------------------
/client/src/components/tracks/tracks.css:
--------------------------------------------------------------------------------
1 | .tracks {
2 | height: 100%;
3 | overflow: auto;
4 | }
5 |
6 | .tracks-new {
7 | font-size: 1.5rem;
8 | height: 40px;
9 | margin: 10px 30px;
10 | width: 70px;
11 | }
12 |
13 | .tracks::-webkit-scrollbar {
14 | background-color: inherit;
15 | width: 10px;
16 | }
17 |
18 | .tracks::-webkit-scrollbar-thumb {
19 | background-color: black;
20 | background-color: rgba(0, 0, 0, 0.3);
21 | border-radius: 5px;
22 | width: 5px;
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/dnd/item-types.js:
--------------------------------------------------------------------------------
1 | export default {
2 | NOTE: 'note',
3 | };
4 |
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | color: #DDD;
3 | font-family: 'Titillium Web', sans-serif;
4 | font-weight: 300;
5 | height: 100%;
6 | width: 100%;
7 | }
8 |
9 | a {
10 | color: inherit;
11 | }
12 |
13 | #main {
14 | height: 100%;
15 | width: 100%;
16 | }
17 |
18 | button, input {
19 | font-family: 'Titillium Web', sans-serif;
20 | }
21 |
22 | .button-dark {
23 | background-color: #151719;
24 | background-color: rgba(0, 0, 0, 0.5);
25 | border: none;
26 | border-bottom: 1px solid transparent;
27 | color: #DDD;
28 | }
29 |
30 | .button-dark:focus {
31 | border-bottom: 1px solid #888;
32 | outline: none;
33 | }
34 |
35 | .button-dark:disabled {
36 | color: #777;
37 | background-color: #333;
38 | background-color: rgba(0, 0, 0, 0.1);
39 | }
40 |
41 | .button-light {
42 | background-color: #7F8285;
43 | background-color: rgba(255, 255, 255, 0.4);
44 | border: none;
45 | border-bottom: 1px solid transparent;
46 | color: #111;
47 | }
48 |
49 | .button-light:focus {
50 | border-bottom: 1px solid white;
51 | outline: none;
52 | }
53 |
54 | .button-light:disabled {
55 | background-color: #333;
56 | background-color: rgba(0, 0, 0, 0.1);
57 | }
58 |
59 | .button-link {
60 | align-self: flex-start;
61 | background-color: transparent;
62 | border: none;
63 | color: #DDD;
64 | cursor: pointer;
65 | text-decoration: underline;
66 | }
67 |
68 | .slider {
69 | -moz-appearance: none;
70 | -webkit-appearance: none;
71 | background-color: rgb(0, 0, 0);
72 | background-color: rgba(0, 0, 0, 0.4);
73 | height: 12px;
74 | width: 110px;
75 | }
76 |
77 | .slider:focus {
78 | background-color: rgb(0, 0, 0);
79 | outline: none;
80 | }
81 |
82 | .slider::-webkit-slider-thumb {
83 | -webkit-appearance: none;
84 | appearance: none;
85 | background-color: rgb(255, 255, 255);
86 | background-color: rgba(255, 255, 255, 0.5);
87 | height: 10px;
88 | width: 10px;
89 | }
90 |
91 | .slider::-moz-range-thumb {
92 | -moz-appearance: none;
93 | appearance: none;
94 | background-color: rgb(255, 255, 255);
95 | background-color: rgba(255, 255, 255, 0.5);
96 | height: 10px;
97 | width: 10px;
98 | }
99 |
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import Tone from 'tone';
4 | import { createStore } from 'redux';
5 | import { Provider } from 'react-redux';
6 | import { BrowserRouter, Route, Switch } from 'react-router-dom';
7 | import reducers from './redux/reducers';
8 | import registerServiceWorker from './registerServiceWorker';
9 |
10 | import 'normalize.css';
11 | import './index.css';
12 |
13 | import App from './components/app';
14 |
15 | import Sequencer from './sequencer';
16 |
17 | // This makes sure the Tone AudioContext doesn't get prevented from starting up.
18 | // More details here: https://github.com/Tonejs/Tone.js/issues/341
19 | document.documentElement.addEventListener('mousedown', () => {
20 | if (Tone.context.state !== 'running')
21 | Tone.context.resume();
22 | });
23 |
24 | // if we're in a dev environment and have the redux devtools, use them
25 | let store;
26 | if (process.env.NODE_ENV === 'development' && window.__REDUX_DEVTOOLS_EXTENSION__) {
27 | store = createStore(
28 | reducers,
29 | window.__REDUX_DEVTOOLS_EXTENSION__()
30 | );
31 | }
32 | else
33 | store = createStore(reducers);
34 |
35 | new Sequencer(store);
36 |
37 | ReactDOM.render(
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | , document.getElementById('main')
47 | );
48 | registerServiceWorker();
49 |
--------------------------------------------------------------------------------
/client/src/redux/actions/actions-clipboard.js:
--------------------------------------------------------------------------------
1 | export const COPY_BUCKET = 'copy_bucket';
2 | export const PASTE_BUCKET = 'paste_bucket';
3 |
4 | export function copyBucket(notes) {
5 | return {
6 | type: COPY_BUCKET,
7 | payload: notes.map(note => note.value)
8 | };
9 | }
10 |
11 | export function pasteBucket({ trackId, bucketId, notes }) {
12 | return {
13 | type: PASTE_BUCKET,
14 | payload: { trackId, bucketId, notes }
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/redux/actions/actions-project.js:
--------------------------------------------------------------------------------
1 | // PROJECT ACTIONS
2 |
3 | export const PLAY = 'play';
4 | export const STOP = 'stop';
5 | export const CHANGE_PROJECT_NAME = 'change_project_name';
6 | export const UPDATE_TEST_NOTE = 'update_test_note';
7 | export const LOAD_PROJECT = 'load_project';
8 | export const DELETE_PROJECT = 'delete_project';
9 | export const CREATE_NEW_PROJECT = 'create_new_project';
10 | export const CHANGE_BPM = 'change_bpm';
11 | export const SHARE = 'share';
12 | export const UNSHARE = 'unshare';
13 |
14 | export const play = () => ({ type: PLAY });
15 | export const stop = () => ({ type: STOP });
16 |
17 | export const changeProjectName = ({ name }) => ({
18 | type: CHANGE_PROJECT_NAME,
19 | payload: { name }
20 | });
21 |
22 | export const updateTestNote = ({ on, value }) => ({
23 | type: UPDATE_TEST_NOTE,
24 | payload: { on, value }
25 | });
26 |
27 | export const loadProject = ({ data, id }) => ({
28 | type: LOAD_PROJECT,
29 | payload: { data, id }
30 | });
31 |
32 | export const deleteProject = (id) => ({
33 | type: DELETE_PROJECT,
34 | payload: id
35 | });
36 |
37 | export const createNewProject = () => ({
38 | type: CREATE_NEW_PROJECT
39 | });
40 |
41 | export const changeBPM = ({ bpm }) => ({
42 | type: CHANGE_BPM,
43 | payload: { bpm }
44 | });
45 |
46 | export const share = () => ({
47 | type: SHARE
48 | });
49 |
50 | export const unshare = () => ({
51 | type: UNSHARE
52 | });
53 |
--------------------------------------------------------------------------------
/client/src/redux/actions/actions-sequence.js:
--------------------------------------------------------------------------------
1 | // SEQUENCE ACTIONS
2 |
3 | export const ADD_NOTE = 'add_note';
4 | export const DELETE_NOTE = 'delete_note';
5 | export const MOVE_NOTE = 'move_note';
6 | export const ADD_BUCKET = 'add_bucket';
7 | export const DELETE_BUCKET = 'delete_bucket';
8 | export const CLEAR_BUCKET = 'clear_bucket';
9 |
10 | export function addNote({ value, id=null, index, bucketId, trackId }) {
11 | return {
12 | type: ADD_NOTE,
13 | payload: { value, id, index, bucketId, trackId }
14 | };
15 | }
16 |
17 | export function deleteNote({ noteIndex, bucketId, trackId }) {
18 | return {
19 | type: DELETE_NOTE,
20 | payload: { noteIndex, bucketId, trackId }
21 | };
22 | }
23 |
24 | export function moveNote({ source, target }) {
25 | return {
26 | type: MOVE_NOTE,
27 | payload: { source, target }
28 | };
29 | }
30 |
31 | export function addBucket({ trackId }) {
32 | return {
33 | type: ADD_BUCKET,
34 | payload: { trackId }
35 | };
36 | }
37 |
38 | export function deleteBucket({ trackId, bucketId }) {
39 | return {
40 | type: DELETE_BUCKET,
41 | payload: { trackId, bucketId }
42 | };
43 | }
44 |
45 | export function clearBucket({ trackId, bucketId }) {
46 | return {
47 | type: CLEAR_BUCKET,
48 | payload: { trackId, bucketId }
49 | };
50 | }
51 |
--------------------------------------------------------------------------------
/client/src/redux/actions/actions-synth.js:
--------------------------------------------------------------------------------
1 | // TRACK ACTIONS
2 |
3 | export const UPDATE_ATTACK = 'update_attack';
4 | export const UPDATE_DECAY = 'update_decay';
5 | export const UPDATE_SUSTAIN = 'update_sustain';
6 | export const UPDATE_RELEASE = 'update_release';
7 | export const UPDATE_OSCILLATOR_TYPE = 'update_oscillator_type';
8 | export const UPDATE_OSCILLATOR_DETUNE = 'update_oscillator_detune';
9 |
10 |
11 | export const updateAttack = ({ value, trackId }) => ({
12 | type: UPDATE_ATTACK,
13 | payload: { value, trackId }
14 | });
15 |
16 | export const updateDecay = ({ value, trackId }) => ({
17 | type: UPDATE_DECAY,
18 | payload: { value, trackId }
19 | });
20 |
21 | export const updateSustain = ({ value, trackId }) => ({
22 | type: UPDATE_SUSTAIN,
23 | payload: { value, trackId }
24 | });
25 |
26 | export const updateRelease = ({ value, trackId }) => ({
27 | type: UPDATE_RELEASE,
28 | payload: { value, trackId }
29 | });
30 |
31 | export const updateOscillatorType = ({ type, trackId }) => ({
32 | type: UPDATE_OSCILLATOR_TYPE,
33 | payload: { type, trackId }
34 | });
35 |
36 | export const updateOscillatorDetune = ({ value, trackId }) => ({
37 | type: UPDATE_OSCILLATOR_DETUNE,
38 | payload: { value, trackId }
39 | });
40 |
41 |
--------------------------------------------------------------------------------
/client/src/redux/actions/actions-track.js:
--------------------------------------------------------------------------------
1 | // TRACK ACTIONS
2 |
3 | export const UPDATE_CURRENT_NOTE = 'update_current_note';
4 | export const MUTE = 'mute';
5 | export const SOLO = 'solo';
6 | export const UNMUTE = 'unmute';
7 | export const UNSOLO = 'unsolo';
8 | export const CHANGE_BASE_NOTE = 'change_base_note';
9 | export const CHANGE_TRACK_NAME = 'change_track_name';
10 | export const UPDATE_TRACK_VOLUME = 'update_track_volume';
11 | export const UPDATE_FILTER_FREQUENCY = 'update_filter_frequency';
12 | export const UPDATE_FILTER_TYPE = 'update_filter_type';
13 | export const UPDATE_FILTER_RESONANCE = 'update_filter_resonance';
14 |
15 |
16 | export function updateCurrentNote({ bucketId, noteIndex, trackId }) {
17 | return {
18 | type: UPDATE_CURRENT_NOTE,
19 | payload: { bucketId, noteIndex, trackId }
20 | };
21 | }
22 |
23 | export function mute(trackId) {
24 | return {
25 | type: MUTE,
26 | payload: trackId
27 | };
28 | }
29 |
30 | export function unmute(trackId) {
31 | return {
32 | type: UNMUTE,
33 | payload: trackId
34 | };
35 | }
36 |
37 | export function solo(trackId) {
38 | return {
39 | type: SOLO,
40 | payload: trackId
41 | };
42 | }
43 |
44 | export function unsolo(trackId) {
45 | return {
46 | type: UNSOLO,
47 | payload: trackId
48 | };
49 | }
50 |
51 | export function changeBaseNote({ baseNote, trackId }) {
52 | return {
53 | type: CHANGE_BASE_NOTE,
54 | payload: { baseNote, trackId }
55 | };
56 | }
57 |
58 | export function changeTrackName({ name, trackId }) {
59 | return {
60 | type: CHANGE_TRACK_NAME,
61 | payload: { name, trackId }
62 | };
63 | }
64 |
65 | export function updateTrackVolume({ volume, trackId }) {
66 | return {
67 | type: UPDATE_TRACK_VOLUME,
68 | payload: { volume, trackId }
69 | };
70 | }
71 |
72 | export const updateFilterFrequency = ({ value, trackId }) => ({
73 | type: UPDATE_FILTER_FREQUENCY,
74 | payload: { value, trackId }
75 | });
76 |
77 | export const updateFilterType = ({ type, trackId }) => ({
78 | type: UPDATE_FILTER_TYPE,
79 | payload: { type, trackId }
80 | });
81 |
82 | export const updateFilterResonance = ({ value, trackId }) => ({
83 | type: UPDATE_FILTER_RESONANCE,
84 | payload: { value, trackId }
85 | });
86 |
--------------------------------------------------------------------------------
/client/src/redux/actions/actions-tracks.js:
--------------------------------------------------------------------------------
1 | // TRACKS ACTIONS
2 |
3 | export const ADD_TRACK = 'add_track';
4 | export const DELETE_TRACK = 'delete_track';
5 |
6 | export function addTrack() {
7 | return {
8 | type: ADD_TRACK
9 | };
10 | }
11 |
12 | export function deleteTrack({ trackId }) {
13 | return {
14 | type: DELETE_TRACK,
15 | payload: { trackId }
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/redux/actions/actions-user.js:
--------------------------------------------------------------------------------
1 | // USER ACTIONS
2 |
3 | export const SET_USER = 'set_user';
4 | export const SAVE = 'save';
5 | export const LOAD_PROJECTS = 'load_projects';
6 |
7 | export const setUser = ({ email, id }) => ({
8 | type: SET_USER,
9 | payload: { email, id }
10 | });
11 |
12 | export const save = ({ id, name }) => ({
13 | type: SAVE,
14 | payload: { id, name }
15 | });
16 |
17 | export const loadProjects = (projects) => ({
18 | type: LOAD_PROJECTS,
19 | payload: projects
20 | });
21 |
--------------------------------------------------------------------------------
/client/src/redux/observers/index.js:
--------------------------------------------------------------------------------
1 | export function observeStore(store, select, onChange) {
2 | let currentState;
3 |
4 | const handleChange = () => {
5 | let newState;
6 | try {
7 | newState = select(store.getState());
8 | }
9 | // when listeners unsubscribe, handleChange will still run one more time, which will
10 | // sometimes throw an error, which we can ignore
11 | catch (e) {
12 | return;
13 | }
14 | if (newState !== currentState) {
15 | onChange(newState, currentState);
16 | currentState = newState;
17 | }
18 | };
19 |
20 | handleChange();
21 | let unsubscribe = store.subscribe(handleChange);
22 | return unsubscribe;
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/redux/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import project from './reducer-project';
3 | import user from './reducer-user';
4 | import clipboard from './reducer-clipboard';
5 |
6 | const rootReducer = combineReducers({ project, user, clipboard });
7 |
8 | export default rootReducer;
9 |
--------------------------------------------------------------------------------
/client/src/redux/reducers/reducer-bucket.js:
--------------------------------------------------------------------------------
1 | // BUCKET REDUCER
2 |
3 | import {
4 | ADD_NOTE,
5 | DELETE_NOTE,
6 | MOVE_NOTE,
7 | CLEAR_BUCKET } from '../actions/actions-sequence';
8 |
9 | import { PASTE_BUCKET } from '../actions/actions-clipboard';
10 |
11 | export default function BucketReducer(state, action, id) {
12 | let newState;
13 | const { payload } = action;
14 | switch(action.type) {
15 |
16 | case ADD_NOTE:
17 | return addNote(state, payload, id);
18 |
19 | case DELETE_NOTE:
20 | newState = [ ...state ];
21 | newState.splice(payload.noteIndex, 1);
22 | return newState;
23 |
24 | case MOVE_NOTE:
25 | newState = [ ...state ];
26 | newState.splice(
27 | payload.target.index,
28 | 0,
29 | newState.splice(payload.source.index, 1)[0]
30 | );
31 | return newState;
32 |
33 | case CLEAR_BUCKET:
34 | return [];
35 |
36 | case PASTE_BUCKET:
37 | return action.payload.notes.map((value, i) => ({
38 | value,
39 | id: action.payload.nextId + i
40 | }));
41 |
42 | default:
43 | return state;
44 | }
45 | }
46 |
47 | function addNote(state, payload) {
48 | const newState = [ ...state ];
49 | const noteObject = { id: payload.id, value: payload.value };
50 | newState.splice(payload.index, 0, noteObject);
51 | return newState;
52 | }
53 |
--------------------------------------------------------------------------------
/client/src/redux/reducers/reducer-clipboard.js:
--------------------------------------------------------------------------------
1 | import { COPY_BUCKET } from '../actions/actions-clipboard';
2 |
3 | export default function(state = [], action) {
4 | switch (action.type) {
5 |
6 | case COPY_BUCKET:
7 | return action.payload;
8 |
9 | default:
10 | return state;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/client/src/redux/reducers/reducer-notes.js:
--------------------------------------------------------------------------------
1 | import { DELETE_NOTE, MOVE_NOTE } from '../actions';
2 |
3 | export default function NotesReducer(state, action) {
4 | const { payload } = action;
5 | let newState;
6 |
7 | switch(action.type) {
8 | case MOVE_NOTE:
9 | newState = [ ...state ];
10 | newState.splice(payload.target.index, 0, newState.splice(payload.source.index, 1)[0]);
11 | return newState;
12 |
13 | case DELETE_NOTE:
14 | newState = [ ...state ];
15 | newState.splice(payload.noteIndex, 1);
16 | return newState;
17 |
18 | default:
19 | return state;
20 | }
21 | }
22 |
23 |
24 | // function addNote(state, note, index) {
25 | // const newState = [ ...state ];
26 | // const noteObject =
27 | // }
28 |
--------------------------------------------------------------------------------
/client/src/redux/reducers/reducer-project.js:
--------------------------------------------------------------------------------
1 | // PROJECT REDUCER
2 |
3 | import { SET_USER, SAVE } from '../../redux/actions/actions-user';
4 | import { mario, starterData } from '../default-data';
5 |
6 | import {
7 | PLAY,
8 | STOP,
9 | CHANGE_PROJECT_NAME,
10 | UPDATE_TEST_NOTE,
11 | LOAD_PROJECT,
12 | DELETE_PROJECT,
13 | CREATE_NEW_PROJECT,
14 | CHANGE_BPM,
15 | SHARE,
16 | UNSHARE
17 | } from '../actions/actions-project';
18 |
19 | import {
20 | ADD_TRACK,
21 | DELETE_TRACK
22 | } from '../actions/actions-tracks';
23 |
24 | import {
25 | UPDATE_CURRENT_NOTE,
26 | MUTE,
27 | SOLO,
28 | UNMUTE,
29 | UNSOLO,
30 | CHANGE_BASE_NOTE,
31 | CHANGE_TRACK_NAME,
32 | UPDATE_TRACK_VOLUME,
33 | UPDATE_FILTER_FREQUENCY,
34 | UPDATE_FILTER_TYPE,
35 | UPDATE_FILTER_RESONANCE
36 | } from '../actions/actions-track.js';
37 |
38 | import {
39 | ADD_NOTE,
40 | DELETE_NOTE,
41 | MOVE_NOTE,
42 | ADD_BUCKET,
43 | DELETE_BUCKET,
44 | CLEAR_BUCKET
45 | } from '../actions/actions-sequence.js';
46 |
47 | import {
48 | UPDATE_ATTACK,
49 | UPDATE_DECAY,
50 | UPDATE_SUSTAIN,
51 | UPDATE_RELEASE,
52 | UPDATE_OSCILLATOR_TYPE,
53 | UPDATE_OSCILLATOR_DETUNE
54 | } from '../actions/actions-synth';
55 |
56 | import { PASTE_BUCKET } from '../actions/actions-clipboard';
57 |
58 | import TracksReducer from './reducer-tracks.js';
59 |
60 | export default function(state = mario(), action) {
61 | let newState;
62 |
63 | switch (action.type) {
64 | case PLAY:
65 | newState = { ...state };
66 | newState.playing = true;
67 | return newState;
68 |
69 | case STOP:
70 | newState = { ...state };
71 | newState.playing = false;
72 | newState.tracks = TracksReducer(newState.tracks, action);
73 | return newState;
74 |
75 | case CHANGE_PROJECT_NAME:
76 | newState = { ...state };
77 | if (action.payload.name !== '')
78 | newState.name = action.payload.name;
79 | return newState;
80 |
81 | case UPDATE_TEST_NOTE:
82 | newState = { ...state };
83 | newState.testNote = action.payload;
84 | return newState;
85 |
86 | case ADD_TRACK:
87 | case DELETE_TRACK:
88 | case UPDATE_CURRENT_NOTE:
89 | case MUTE:
90 | case SOLO:
91 | case UNMUTE:
92 | case UNSOLO:
93 | case CHANGE_BASE_NOTE:
94 | case CHANGE_TRACK_NAME:
95 | case UPDATE_TRACK_VOLUME:
96 | case UPDATE_FILTER_FREQUENCY:
97 | case UPDATE_FILTER_TYPE:
98 | case UPDATE_FILTER_RESONANCE:
99 | case ADD_NOTE:
100 | case DELETE_NOTE:
101 | case MOVE_NOTE:
102 | case ADD_BUCKET:
103 | case DELETE_BUCKET:
104 | case CLEAR_BUCKET:
105 | case PASTE_BUCKET:
106 | case UPDATE_ATTACK:
107 | case UPDATE_DECAY:
108 | case UPDATE_SUSTAIN:
109 | case UPDATE_RELEASE:
110 | case UPDATE_OSCILLATOR_TYPE:
111 | case UPDATE_OSCILLATOR_DETUNE:
112 | newState = { ...state };
113 | newState.tracks = TracksReducer(newState.tracks, action);
114 | return newState;
115 |
116 | case SAVE:
117 | if (state.hasOwnProperty(action.payload.id))
118 | return state;
119 | newState = { ...state };
120 | newState.id = action.payload.id;
121 | return newState;
122 |
123 | case LOAD_PROJECT:
124 | newState = action.payload.data;
125 | newState.id = action.payload.id;
126 | newState.playing = false;
127 | newState.testNote = { on: false, value: '' };
128 | newState.tracks = TracksReducer(newState.tracks, action);
129 | return newState;
130 |
131 | case DELETE_PROJECT:
132 | return generateEmptyProject();
133 |
134 | case CREATE_NEW_PROJECT:
135 | return simulateEmptyProject(state, action);
136 |
137 | case SET_USER:
138 | if (!action.payload.email && !action.payload.id)
139 | return simulateEmptyProject(state, action);
140 | return state;
141 |
142 | case CHANGE_BPM:
143 | newState = { ...state };
144 | newState.bpm = action.payload.bpm;
145 | return newState;
146 |
147 | case SHARE:
148 | case UNSHARE:
149 | newState = { ...state };
150 | newState.shared = !state.shared;
151 | return newState;
152 |
153 | default:
154 | return state;
155 | }
156 | }
157 |
158 | function generateEmptyProject() {
159 | const newState = { ...starterData() };
160 | const [ id1, id2 ] = Object.keys(newState.tracks);
161 | delete newState.tracks[id2];
162 | newState.tracks[id1].sequence = [[], [], [], [], [], [], [], []];
163 | return newState;
164 | }
165 |
166 | function simulateEmptyProject(state, action) {
167 | if (state.id || Object.keys(state.tracks).length !== 1)
168 | return generateEmptyProject();
169 | const newState = { ...state };
170 | newState.tracks = TracksReducer(newState.tracks, action);
171 | return newState;
172 | }
173 |
--------------------------------------------------------------------------------
/client/src/redux/reducers/reducer-sequence.js:
--------------------------------------------------------------------------------
1 | // SEQUENCE REDUCER
2 |
3 | import {
4 | ADD_NOTE,
5 | DELETE_NOTE,
6 | MOVE_NOTE,
7 | ADD_BUCKET,
8 | DELETE_BUCKET,
9 | CLEAR_BUCKET,
10 | deleteNote,
11 | addNote
12 | } from '../actions/actions-sequence';
13 |
14 | import { PASTE_BUCKET } from '../actions/actions-clipboard';
15 |
16 | import BucketReducer from './reducer-bucket';
17 |
18 | export default function SequenceReducer(state, action) {
19 | let newState;
20 | const { payload, type } = action;
21 | switch (type) {
22 |
23 | case ADD_NOTE:
24 | case DELETE_NOTE:
25 | case CLEAR_BUCKET:
26 | case PASTE_BUCKET:
27 | newState = [ ...state ];
28 | newState[payload.bucketId] = BucketReducer(newState[payload.bucketId], action);
29 | return newState;
30 |
31 | case MOVE_NOTE:
32 | newState = [ ...state ];
33 |
34 | // if moving within the same bucket
35 | if (payload.source.bucket === payload.target.bucket) {
36 | newState[payload.source.bucket]
37 | = BucketReducer(newState[payload.source.bucket], action);
38 | }
39 | // if moving from one bucket to another
40 | else {
41 | newState[payload.source.bucket]
42 | = delFromMove(newState[payload.source.bucket], payload);
43 |
44 | newState[payload.target.bucket]
45 | = addFromMove(newState[payload.target.bucket], payload);
46 | }
47 | return newState;
48 |
49 | case ADD_BUCKET:
50 | return [ ...state, [] ];
51 |
52 | case DELETE_BUCKET:
53 | newState = [ ...state ];
54 | newState.splice(action.payload.bucketId, 1);
55 | return newState;
56 |
57 | default:
58 | return state;
59 | }
60 | }
61 |
62 | function delFromMove(state, payload) {
63 | const action = deleteNote({
64 | noteIndex: payload.source.index,
65 | // noteId: payload.source.id,
66 | bucketId: payload.source.bucket,
67 | trackId: payload.track
68 | });
69 | return BucketReducer(state, action);
70 | }
71 |
72 | function addFromMove(state, payload) {
73 | const action = addNote({
74 | value: payload.source.value,
75 | id: payload.source.id,
76 | index: payload.target.index,
77 | bucketId: payload.target.bucket,
78 | trackId: payload.track
79 | });
80 | return BucketReducer(state, action);
81 | }
82 |
--------------------------------------------------------------------------------
/client/src/redux/reducers/reducer-synth.js:
--------------------------------------------------------------------------------
1 | // SEQUENCE REDUCER
2 |
3 | import {
4 | UPDATE_ATTACK,
5 | UPDATE_DECAY,
6 | UPDATE_SUSTAIN,
7 | UPDATE_RELEASE,
8 | UPDATE_OSCILLATOR_TYPE,
9 | UPDATE_OSCILLATOR_DETUNE
10 | } from '../actions/actions-synth';
11 |
12 | export default function SynthReducer(state, action) {
13 | let newState;
14 | const { payload, type } = action;
15 | switch (type) {
16 |
17 | case UPDATE_ATTACK:
18 | newState = { ...state };
19 | newState.envelope = { ...newState.envelope };
20 | newState.envelope.attack = payload.value;
21 | return newState;
22 |
23 | case UPDATE_DECAY:
24 | newState = { ...state };
25 | newState.envelope = { ...newState.envelope };
26 | newState.envelope.decay = payload.value;
27 | return newState;
28 |
29 | case UPDATE_SUSTAIN:
30 | newState = { ...state };
31 | newState.envelope = { ...newState.envelope };
32 | newState.envelope.sustain = payload.value;
33 | return newState;
34 |
35 | case UPDATE_RELEASE:
36 | newState = { ...state };
37 | newState.envelope = { ...newState.envelope };
38 | newState.envelope.release = payload.value;
39 | return newState;
40 |
41 | case UPDATE_OSCILLATOR_TYPE:
42 | newState = { ...state };
43 | newState.oscillator = { ...newState.oscillator };
44 | newState.oscillator.type = action.payload.type;
45 | return newState;
46 |
47 | case UPDATE_OSCILLATOR_DETUNE:
48 | newState = { ...state };
49 | newState.oscillator = { ...newState.oscillator };
50 | newState.oscillator.detune = action.payload.value;
51 | return newState;
52 |
53 | default:
54 | return state;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/client/src/redux/reducers/reducer-tracks.js:
--------------------------------------------------------------------------------
1 | // TRACKS REDUCER
2 |
3 | import uuidv4 from 'uuid/v4';
4 |
5 | import { STOP, LOAD_PROJECT, CREATE_NEW_PROJECT } from '../actions/actions-project.js';
6 |
7 | import {
8 | ADD_TRACK,
9 | DELETE_TRACK
10 | } from '../actions/actions-tracks';
11 |
12 | import {
13 | UPDATE_CURRENT_NOTE,
14 | MUTE,
15 | SOLO,
16 | UNMUTE,
17 | UNSOLO,
18 | CHANGE_BASE_NOTE,
19 | CHANGE_TRACK_NAME,
20 | UPDATE_TRACK_VOLUME,
21 | UPDATE_FILTER_FREQUENCY,
22 | UPDATE_FILTER_TYPE,
23 | UPDATE_FILTER_RESONANCE
24 | } from '../actions/actions-track.js';
25 |
26 | import {
27 | ADD_NOTE,
28 | DELETE_NOTE,
29 | MOVE_NOTE,
30 | ADD_BUCKET,
31 | DELETE_BUCKET,
32 | CLEAR_BUCKET
33 | } from '../actions/actions-sequence.js';
34 |
35 | import {
36 | UPDATE_ATTACK,
37 | UPDATE_DECAY,
38 | UPDATE_SUSTAIN,
39 | UPDATE_RELEASE,
40 | UPDATE_OSCILLATOR_TYPE,
41 | UPDATE_OSCILLATOR_DETUNE
42 | } from '../actions/actions-synth';
43 |
44 | import { PASTE_BUCKET } from '../actions/actions-clipboard.js';
45 |
46 | import { defaultTrack } from '../default-data';
47 |
48 | import TrackReducer from './reducer-track';
49 |
50 | export default function TracksReducer(state, action) {
51 | let newState;
52 | let targetTrack;
53 |
54 | switch (action.type) {
55 | case MUTE:
56 | newState = { ...state };
57 | targetTrack = newState[action.payload];
58 | newState[action.payload] = TrackReducer(targetTrack, action);
59 | return newState;
60 |
61 | case UNMUTE:
62 | newState = { ...state };
63 | targetTrack = newState[action.payload];
64 | // only unmute the track if no other tracks are soloed
65 | if (!~getSoloedTrack(state))
66 | newState[action.payload] = TrackReducer(targetTrack, action);
67 | return newState;
68 |
69 | case SOLO:
70 | case UNSOLO:
71 | newState = {};
72 | Object.values(state).forEach(track => {
73 | newState[track.id] = TrackReducer(track, action);
74 | });
75 | return newState;
76 |
77 | case UPDATE_CURRENT_NOTE:
78 | case DELETE_NOTE:
79 | case ADD_NOTE:
80 | case ADD_BUCKET:
81 | case DELETE_BUCKET:
82 | case CLEAR_BUCKET:
83 | case CHANGE_BASE_NOTE:
84 | case CHANGE_TRACK_NAME:
85 | case UPDATE_TRACK_VOLUME:
86 | case UPDATE_FILTER_FREQUENCY:
87 | case UPDATE_FILTER_TYPE:
88 | case UPDATE_FILTER_RESONANCE:
89 | case PASTE_BUCKET:
90 | case UPDATE_ATTACK:
91 | case UPDATE_DECAY:
92 | case UPDATE_SUSTAIN:
93 | case UPDATE_RELEASE:
94 | case UPDATE_OSCILLATOR_TYPE:
95 | case UPDATE_OSCILLATOR_DETUNE:
96 | newState = { ...state };
97 | targetTrack = newState[action.payload.trackId];
98 | newState[action.payload.trackId] = TrackReducer(targetTrack, action);
99 | return newState;
100 |
101 | case MOVE_NOTE:
102 | return moveNote(state, action);
103 |
104 | case ADD_TRACK:
105 | return addTrack(state);
106 |
107 | case DELETE_TRACK:
108 | newState = { ...state };
109 | delete newState[action.payload.trackId];
110 | return newState;
111 |
112 | case STOP:
113 | case LOAD_PROJECT:
114 | newState = { ...state };
115 | Object.keys(newState).forEach(key => {
116 | newState[key] = TrackReducer(newState[key], action);
117 | });
118 | return newState;
119 |
120 | // we only get this deep with CREATE_NEW_PROJECT if we're leaving an unsaved project
121 | // with only one track. we want to keep that track's id, but reset its sequence
122 | case CREATE_NEW_PROJECT:
123 | return handleCreateNewProject(state, action);
124 |
125 | default:
126 | return state;
127 |
128 | }
129 | }
130 |
131 | function handleCreateNewProject(state, action) {
132 | const newState = { ...state };
133 | const id = Object.keys(newState)[0];
134 | newState[id] = TrackReducer(newState[id], action);
135 | return newState;
136 | }
137 |
138 | function moveNote(state, action) {
139 | const newState = { ...state };
140 | const sourceId = action.payload.source.trackId;
141 | const targetId = action.payload.target.trackId;
142 |
143 | if (sourceId !== targetId) {
144 | const targetTrack = newState[targetId];
145 | newState[targetId] = TrackReducer(targetTrack, action);
146 | }
147 |
148 | const sourceTrack = newState[sourceId];
149 | newState[sourceId] = TrackReducer(sourceTrack, action);
150 | return newState;
151 | }
152 |
153 | function getSoloedTrack(state) {
154 | const soloed = Object.values(state).filter(track => track.soloed);
155 | if (soloed.length > 0)
156 | return soloed[0].id;
157 | return -1;
158 | }
159 |
160 | function addTrack(state) {
161 | const newState = { ...state };
162 | const trackKeys = Object.keys(newState);
163 | const id = uuidv4();
164 | newState[id] = defaultTrack(trackKeys.length + 1, id);
165 | return newState;
166 | }
167 |
--------------------------------------------------------------------------------
/client/src/redux/reducers/reducer-user.js:
--------------------------------------------------------------------------------
1 | // USER REDUCER
2 | import { SET_USER, SAVE, LOAD_PROJECTS } from '../actions/actions-user.js';
3 | import {
4 | CHANGE_PROJECT_NAME,
5 | LOAD_PROJECT,
6 | DELETE_PROJECT,
7 | CHANGE_BPM,
8 | CREATE_NEW_PROJECT } from '../actions/actions-project.js';
9 | import { ADD_TRACK, DELETE_TRACK } from '../actions/actions-tracks.js';
10 | import {
11 | MUTE,
12 | SOLO,
13 | UNMUTE,
14 | UNSOLO,
15 | CHANGE_BASE_NOTE,
16 | CHANGE_TRACK_NAME,
17 | UPDATE_TRACK_VOLUME,
18 | UPDATE_FILTER_FREQUENCY,
19 | UPDATE_FILTER_TYPE,
20 | UPDATE_FILTER_RESONANCE } from '../actions/actions-track.js';
21 | import {
22 | ADD_NOTE,
23 | DELETE_NOTE,
24 | MOVE_NOTE,
25 | ADD_BUCKET,
26 | DELETE_BUCKET } from '../actions/actions-sequence.js';
27 | import {
28 | UPDATE_ATTACK,
29 | UPDATE_DECAY,
30 | UPDATE_SUSTAIN,
31 | UPDATE_RELEASE,
32 | UPDATE_OSCILLATOR_TYPE,
33 | UPDATE_OSCILLATOR_DETUNE
34 | } from '../actions/actions-synth.js';
35 |
36 | const starter = {
37 | email: null,
38 | id: null,
39 | canSave: true,
40 | projects: {}
41 | };
42 |
43 | export default function(state = starter, action) {
44 | let newState;
45 |
46 | switch (action.type) {
47 | case SET_USER:
48 | newState = { ...state };
49 | newState.email = action.payload.email;
50 | newState.id = action.payload.id;
51 | newState.canSave = true;
52 | return newState;
53 |
54 | case LOAD_PROJECT:
55 | // if there's no id (e.g. loading a shared project), no change
56 | if (!action.payload.id)
57 | return state;
58 | newState = { ...state };
59 | newState.canSave = false;
60 | return newState;
61 |
62 | case SAVE:
63 | return save(state, action.payload);
64 |
65 | case LOAD_PROJECTS:
66 | newState = { ...state };
67 | // newState.projects = action.payload;
68 | newState.projects = {};
69 | action.payload.forEach(([id, name]) => { newState.projects[id] = name; });
70 | return newState;
71 |
72 | case DELETE_PROJECT:
73 | newState = { ...state };
74 | newState.projects = { ...newState.projects };
75 | delete newState.projects[action.payload];
76 | newState.canSave = true;
77 | return newState;
78 |
79 | case CHANGE_BPM:
80 | case CREATE_NEW_PROJECT:
81 | case CHANGE_PROJECT_NAME:
82 | case ADD_TRACK:
83 | case DELETE_TRACK:
84 | case MUTE:
85 | case SOLO:
86 | case UNMUTE:
87 | case UNSOLO:
88 | case CHANGE_BASE_NOTE:
89 | case CHANGE_TRACK_NAME:
90 | case UPDATE_TRACK_VOLUME:
91 | case ADD_NOTE:
92 | case DELETE_NOTE:
93 | case MOVE_NOTE:
94 | case ADD_BUCKET:
95 | case DELETE_BUCKET:
96 | case UPDATE_ATTACK:
97 | case UPDATE_DECAY:
98 | case UPDATE_SUSTAIN:
99 | case UPDATE_RELEASE:
100 | case UPDATE_OSCILLATOR_TYPE:
101 | case UPDATE_OSCILLATOR_DETUNE:
102 | case UPDATE_FILTER_FREQUENCY:
103 | case UPDATE_FILTER_TYPE:
104 | case UPDATE_FILTER_RESONANCE:
105 | if (state.canSave)
106 | return state;
107 | newState = { ...state };
108 | newState.canSave = true;
109 | return newState;
110 |
111 | default:
112 | return state;
113 | }
114 | }
115 |
116 | function save(state, { id, name }) {
117 | const newState = { ...state };
118 | newState.canSave = false;
119 | if (newState.projects.hasOwnProperty(id) && name !== newState.projects[id]) {
120 | newState.projects = { ...newState.projects };
121 | newState.projects[id] = name;
122 | }
123 | else if (!newState.projects.hasOwnProperty(id))
124 | newState.projects = { ...newState.projects, [id]: name };
125 |
126 | return newState;
127 | }
128 |
--------------------------------------------------------------------------------
/client/src/redux/selectors/index.js:
--------------------------------------------------------------------------------
1 | export function selectTracks(state) {
2 | return state.project.tracks;
3 | }
4 |
5 | export function selectTrack(id) {
6 | return state => state.project.tracks[id];
7 | }
8 |
9 | export function selectPlaying(state) {
10 | return state.project.playing;
11 | }
12 |
13 | export function selectBPM(state) {
14 | return state.project.bpm;
15 | }
16 |
17 | export function selectMuted(id) {
18 | return state => state.project.tracks[id].muted;
19 | }
20 |
21 | export function selectSequence(id) {
22 | return state => state.project.tracks[id].sequence;
23 | }
24 |
25 | export function selectBaseNote(id) {
26 | return state => state.project.tracks[id].baseNote;
27 | }
28 |
29 | export function selectNextId(id) {
30 | return state => state.project.tracks[id].nextId;
31 | }
32 |
33 | export function selectOctave(state) {
34 | return state.project.octave;
35 | }
36 |
37 | export function selectProject(state) {
38 | return state.project;
39 | }
40 |
41 | export function selectProjectName(state) {
42 | return state.project.name;
43 | }
44 |
45 | export function selectTestNote(state) {
46 | return state.project.testNote;
47 | }
48 |
49 | export function selectTrackVolume(id) {
50 | return state => state.project.tracks[id].volume;
51 | }
52 |
53 | export function selectEmail(state) {
54 | return state.user.email;
55 | }
56 |
57 | export function selectUserId(state) {
58 | return state.user.id;
59 | }
60 |
61 | export function selectCanSave(state) {
62 | return state.user.canSave;
63 | }
64 |
65 | export function selectProjectIdAndTrackCount(state) {
66 | return `${state.project.id},${Object.keys(state.project.tracks).length}`;
67 | }
68 |
69 | export function selectShared(state) {
70 | return state.project.shared;
71 | }
72 |
73 | export function selectProjects(state) {
74 | return state.user.projects;
75 | }
76 |
77 | export function selectClipboard(state) {
78 | return state.clipboard;
79 | }
80 |
81 | export function selectEnvelope(id) {
82 | return state => state.project.tracks[id].synth.envelope;
83 | }
84 |
85 | export function selectOscillator(id) {
86 | return state => state.project.tracks[id].synth.oscillator;
87 | }
88 |
89 | export function selectOscType(id) {
90 | return state => state.project.tracks[id].synth.oscillator.type;
91 | }
92 |
93 | export function selectOscDetune(id) {
94 | return state => state.project.tracks[id].synth.oscillator.detune;
95 | }
96 |
97 | export function selectFilter(id) {
98 | return state => state.project.tracks[id].filter;
99 | }
100 |
101 | export function selectFilterFrequency(id) {
102 | return state => state.project.tracks[id].filter.frequency;
103 | }
104 |
105 | export function selectFilterType(id) {
106 | return state => state.project.tracks[id].filter.type;
107 | }
108 |
109 | export function selectFilterResonance(id) {
110 | return state => state.project.tracks[id].filter.resonance;
111 | }
112 |
--------------------------------------------------------------------------------
/client/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (isLocalhost) {
36 | // This is running on localhost. Lets check if a service worker still exists or not.
37 | checkValidServiceWorker(swUrl);
38 | } else {
39 | // Is not local host. Just register service worker
40 | registerValidSW(swUrl);
41 | }
42 | });
43 | }
44 | }
45 |
46 | function registerValidSW(swUrl) {
47 | navigator.serviceWorker
48 | .register(swUrl)
49 | .then(registration => {
50 | registration.onupdatefound = () => {
51 | const installingWorker = registration.installing;
52 | installingWorker.onstatechange = () => {
53 | if (installingWorker.state === 'installed') {
54 | if (navigator.serviceWorker.controller) {
55 | // At this point, the old content will have been purged and
56 | // the fresh content will have been added to the cache.
57 | // It's the perfect time to display a "New content is
58 | // available; please refresh." message in your web app.
59 | console.log('New content is available; please refresh.');
60 | } else {
61 | // At this point, everything has been precached.
62 | // It's the perfect time to display a
63 | // "Content is cached for offline use." message.
64 | console.log('Content is cached for offline use.');
65 | }
66 | }
67 | };
68 | };
69 | })
70 | .catch(error => {
71 | console.error('Error during service worker registration:', error);
72 | });
73 | }
74 |
75 | function checkValidServiceWorker(swUrl) {
76 | // Check if the service worker can be found. If it can't reload the page.
77 | fetch(swUrl)
78 | .then(response => {
79 | // Ensure service worker exists, and that we really are getting a JS file.
80 | if (
81 | response.status === 404 ||
82 | response.headers.get('content-type').indexOf('javascript') === -1
83 | ) {
84 | // No service worker found. Probably a different app. Reload the page.
85 | navigator.serviceWorker.ready.then(registration => {
86 | registration.unregister().then(() => {
87 | window.location.reload();
88 | });
89 | });
90 | } else {
91 | // Service worker found. Proceed as normal.
92 | registerValidSW(swUrl);
93 | }
94 | })
95 | .catch(() => {
96 | console.log(
97 | 'No internet connection found. App is running in offline mode.'
98 | );
99 | });
100 | }
101 |
102 | export function unregister() {
103 | if ('serviceWorker' in navigator) {
104 | navigator.serviceWorker.ready.then(registration => {
105 | registration.unregister();
106 | });
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/client/src/sequencer/index.js:
--------------------------------------------------------------------------------
1 | // SEQUENCER CLASS
2 |
3 | import Tone from 'tone';
4 | import Track from './track';
5 | import {
6 | selectTracks,
7 | selectPlaying,
8 | selectBPM,
9 | selectTestNote,
10 | selectProjectIdAndTrackCount
11 | } from '../redux/selectors';
12 |
13 | import { observeStore } from '../redux/observers';
14 |
15 | export default class Sequencer {
16 | constructor(store) {
17 | this.store = store;
18 | // handleTrackCountChange will automatically run when the subscriptions are
19 | // instantiated, which will fill up this.tracks
20 | this.tracks = [];
21 | Tone.Transport.bpm.value = selectBPM(store.getState());
22 |
23 | this.synth = new Tone.Synth().toMaster();
24 |
25 | this.subscriptions = [
26 | observeStore(store, selectPlaying, this.handlePlayingChange.bind(this)),
27 | observeStore(store, selectTestNote, this.handleTestNoteChange.bind(this)),
28 | observeStore(store, selectBPM, this.handleBPMChange.bind(this)),
29 | observeStore(
30 | store,
31 | selectProjectIdAndTrackCount,
32 | this.handleProjectIdOrTrackCountChange.bind(this)
33 | )
34 | ];
35 | }
36 |
37 | // play or stop the loop when global 'playing' changes
38 | handlePlayingChange(playing) {
39 | playing
40 | ? Tone.Transport.start('+0.1')
41 | : Tone.Transport.stop();
42 | }
43 |
44 | handleTestNoteChange({ on, value }) {
45 | on
46 | ? this.synth.triggerAttack(value)
47 | : this.synth.triggerRelease();
48 | }
49 |
50 | handleBPMChange(bpm) {
51 | Tone.Transport.bpm.value = bpm;
52 | }
53 |
54 | // if the project id changes, then we've loaded a new project, so we need to reload.
55 | // if the track count changes, we've added or deleted tracks, and the simplest way to
56 | // handle that is to reload all the tracks
57 | handleProjectIdOrTrackCountChange() {
58 | this.tracks.forEach(track => { track.deleteSelf(); });
59 | this.tracks = this.generateTracks();
60 | }
61 |
62 | // initialize Track objects for each track in the store
63 | generateTracks() {
64 | return Object.values(selectTracks(this.store.getState())).map(({ id }) => {
65 | return new Track(this.store, id);
66 | });
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/client/src/sequencer/track.js:
--------------------------------------------------------------------------------
1 | // TRACK CLASS
2 |
3 | import Tone from 'tone';
4 | import { createPartEvents } from './utils';
5 | import {
6 | selectTrack,
7 | selectMuted,
8 | selectSequence,
9 | selectBaseNote,
10 | selectTrackVolume,
11 | selectEnvelope,
12 | selectOscType,
13 | selectOscDetune,
14 | selectFilterFrequency,
15 | selectFilterType,
16 | selectFilterResonance
17 | } from '../redux/selectors';
18 | import { observeStore } from '../redux/observers';
19 | import { updateCurrentNote } from '../redux/actions/actions-track';
20 |
21 | export default class Track {
22 | constructor(store, id) {
23 | this.store = store;
24 | this.id = id;
25 |
26 | const { sequence, baseNote, synth } = selectTrack(id)(store.getState());
27 | this.sequence = sequence;
28 | this.baseNote = baseNote;
29 |
30 | this.filter = new Tone.Filter(20000, 'lowpass', -96).toMaster();
31 |
32 | this.synth = new Tone.Synth({
33 | envelope: synth.envelope,
34 | oscillator: synth.oscillator
35 | }).connect(this.filter);
36 |
37 | this.part = this.initPart(sequence, baseNote);
38 |
39 | this.subscriptions = [
40 | observeStore(store, selectSequence(id), this.onSequenceChange.bind(this)),
41 | observeStore(store, selectMuted(id), this.onMutedChange.bind(this)),
42 | observeStore(store, selectBaseNote(id), this.onBaseNoteChange.bind(this)),
43 | observeStore(store, selectTrackVolume(id), this.onVolumeChange.bind(this)),
44 | observeStore(store, selectEnvelope(id), this.onEnvelopeChange.bind(this)),
45 | observeStore(store, selectOscType(id), this.onOscTypeChange.bind(this)),
46 | observeStore(store, selectOscDetune(id), this.onOscDetuneChange.bind(this)),
47 | observeStore(
48 | store,
49 | selectFilterFrequency(id),
50 | this.onFilterFrequencyChange.bind(this)
51 | ),
52 | observeStore(
53 | store,
54 | selectFilterType(id),
55 | this.onFilterTypeChange.bind(this)
56 | ),
57 | observeStore(
58 | store,
59 | selectFilterResonance(id),
60 | this.onFilterResonanceChange.bind(this)
61 | )
62 | ];
63 | }
64 |
65 | deleteSelf() {
66 | // unsubscribe from all subscriptions
67 | this.subscriptions.forEach(unsubscribe => unsubscribe());
68 |
69 | // dispose of the synth
70 | this.synth.dispose();
71 | this.synth = null;
72 |
73 | // dispose of the part
74 | this.part.dispose();
75 | this.part = null;
76 | }
77 |
78 | initPart(sequence, baseNote) {
79 | const part = new Tone.Part(
80 | this.partProcessor.bind(this),
81 | createPartEvents(sequence, baseNote)
82 | );
83 |
84 | part.start(0);
85 | part.loop = true;
86 | part.loopEnd = `${sequence.length}*0:${(baseNote / 4)}`;
87 |
88 | return part;
89 | }
90 |
91 | partProcessor(time, { value, dur, bucketIndex, noteIndex }) {
92 | // only trigger a note if it's not a rest, but dispatch currentNote in either case
93 | value !== 'rest' &&
94 | this.synth.triggerAttackRelease(value, dur, time);
95 | this.store.dispatch(
96 | updateCurrentNote({ bucketId: bucketIndex, noteIndex, trackId: this.id })
97 | );
98 | }
99 |
100 | onMutedChange(muted) {
101 | muted
102 | ? this.synth.volume.value = -Infinity
103 | : this.synth.volume.value = selectTrackVolume(this.id)(this.store.getState());
104 | }
105 |
106 | onSequenceChange(sequence) {
107 | this.part.removeAll();
108 | this.part = this.initPart(sequence, this.baseNote);
109 | this.sequence = sequence;
110 | }
111 |
112 | onBaseNoteChange(baseNote) {
113 | this.part.removeAll();
114 | this.part = this.initPart(this.sequence, baseNote);
115 | this.baseNote = baseNote;
116 | }
117 |
118 | onVolumeChange(volume) {
119 | this.synth.volume.value = volume;
120 | }
121 |
122 | onEnvelopeChange(envelope) {
123 | for (let item in envelope) {
124 | this.synth.envelope[item] = envelope[item];
125 | }
126 | }
127 |
128 | onOscTypeChange(type) {
129 | const envelope = selectEnvelope(this.id)(this.store.getState());
130 | this.synth.dispose();
131 | this.synth = new Tone.Synth({ envelope, oscillator: { type } }).connect(this.filter);
132 | }
133 |
134 | onOscDetuneChange(detune) {
135 | this.synth.oscillator.detune.value = detune;
136 | }
137 |
138 | onFilterFrequencyChange(frequency) {
139 | this.filter.frequency.value = frequency;
140 | }
141 |
142 | onFilterTypeChange(type) {
143 | const frequency = this.filter.frequency.value;
144 | this.filter.disconnect();
145 | this.filter.dispose();
146 | this.filter = new Tone.Filter(frequency, type, -96).toMaster();
147 | this.synth.connect(this.filter);
148 | }
149 |
150 | onFilterResonanceChange(resonance) {
151 | this.filter.Q.value = resonance;
152 | }
153 |
154 | }
155 |
--------------------------------------------------------------------------------
/client/src/sequencer/utils.js:
--------------------------------------------------------------------------------
1 | export function createPartEvents(sequence, baseNote) {
2 | const events = [];
3 |
4 | // place a dummy 'rest' note in any empty bucket
5 | // this ensures that a previously played note doesn't light up in the view
6 | const mappedSequence = sequence.map(bucket => {
7 | if (bucket.length < 1)
8 | return [{ value: 'rest' }];
9 | return bucket;
10 | });
11 | mappedSequence.forEach((bucket, bucketIndex) => {
12 | bucket.forEach(({ value }, noteIndex) => {
13 | const [ dur, time ] =
14 | getDurAndTime(bucket.length, bucketIndex, noteIndex, baseNote);
15 | events.push({ value, dur, time, noteIndex, bucketIndex });
16 | });
17 | });
18 | return events;
19 | }
20 |
21 | function getDurAndTime(bucketLength, bucketIndex, noteIndex, baseNote) {
22 | const _dur = (baseNote / 4) / bucketLength;
23 | const dur = `0:${_dur}`;
24 | const start = `0:${bucketIndex * (baseNote / 4)}`;
25 | const time = `${start} + 0:${noteIndex * _dur}`;
26 | return [dur, time];
27 | }
28 |
--------------------------------------------------------------------------------
/client/src/sequencer/utils.test.js:
--------------------------------------------------------------------------------
1 | import { createPartEvents } from './utils.js';
2 |
3 | const sequence = [
4 | [{id: 0, value: 'A4'}],
5 | [{id: 1, value: 'B4'}, {id: 2, value: 'C4'}],
6 | [{id: 3, value: 'D4'}]
7 | ];
8 |
9 | describe('createPartEvents', () => {
10 | it('returns an array', () => {
11 | expect(Array.isArray(createPartEvents(sequence, 1))).toBe(true);
12 | });
13 |
14 | it('extracts the right number of notes', () => {
15 | expect(createPartEvents(sequence, 1).length).toBe(4);
16 | });
17 |
18 | // todo:
19 | // flesh out the expected object -- also can you make it work with all of the notes?k
20 | it('creates objects with appropriate keys', () => {
21 | expect(createPartEvents(sequence, 1)[0]).toMatchObject({ note: 'A4' });
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/client/src/utils/index.js:
--------------------------------------------------------------------------------
1 | // GENERAL UTILS
2 | import axios from 'axios';
3 |
4 | export const API_BASE_URL = process.env.REACT_APP_API_URL;
5 | export const WEB_BASE_URL = process.env.REACT_APP_WEB_URL;
6 |
7 | // uses the refresh token to get user data and access token
8 | export function useRefreshToken({ success }) {
9 | const refreshToken = localStorage.getItem('refreshToken');
10 | if (refreshToken) {
11 | axios.get(
12 | `${API_BASE_URL}auth/authenticate`,
13 | { headers: { Authorization: `Bearer ${refreshToken}` } }
14 | )
15 | .then(res => {
16 | const { accessToken, refreshToken } = res.data;
17 | localStorage.setItem('accessToken', accessToken);
18 | refreshToken && localStorage.setItem('refreshToken', refreshToken);
19 | success(res);
20 | })
21 | .catch(() => {
22 | localStorage.removeItem('accessToken');
23 | localStorage.removeItem('refreshToken');
24 | });
25 | }
26 | }
27 |
28 | // registers a new user
29 | export function register(data, handlers) {
30 | axios.post(`${API_BASE_URL}auth/register`, data)
31 | .then(res => {
32 | handlers.success(res);
33 | })
34 | .catch(err => {
35 | handlers.failure(err);
36 | });
37 | }
38 |
39 | // signs a user in
40 | export function signIn(data, handlers) {
41 | axios.post(`${API_BASE_URL}auth/login`, data)
42 | .then(res => {
43 | localStorage.setItem('accessToken', res.data.accessToken);
44 | localStorage.setItem('refreshToken', res.data.refreshToken);
45 | handlers.success(res);
46 | })
47 | .catch(err => {
48 | handlers.failure(err);
49 | });
50 | }
51 |
52 | // handles all requests to the resource server where authentication is required
53 | // if the access token fails, it will use the refresh to get a new access token
54 | // and try the request again
55 | export function resourceRequest(method, path, handlers, data) {
56 | const accessToken = localStorage.getItem('accessToken');
57 | if (accessToken) {
58 | buildRequest(method, path, handlers, accessToken, data)
59 | .then(res => {
60 | handlers.success(res);
61 | })
62 | .catch(() => {
63 | localStorage.removeItem('accessToken');
64 | const refreshToken = localStorage.getItem('refreshToken');
65 | if (refreshToken) {
66 | axios.get(
67 | `${API_BASE_URL}auth/authenticate`,
68 | { headers: { Authorization: `Bearer ${refreshToken}` } }
69 | )
70 | .then(res => {
71 | const { accessToken, refreshToken } = res.data;
72 | localStorage.setItem('accessToken', accessToken);
73 | refreshToken && localStorage.setItem('refreshToken', refreshToken);
74 | buildRequest(method, path, handlers, accessToken, data)
75 | .then(res => { handlers.success(res); })
76 | .catch(err => { handlers.failure(err); });
77 | })
78 | .catch(err => {
79 | localStorage.removeItem('accessToken');
80 | localStorage.removeItem('refreshToken');
81 | handlers.authFailure(err);
82 | });
83 | }
84 | });
85 | }
86 | }
87 |
88 | // sends a request with data if provided, without if not, and returns a promise
89 | function buildRequest(method, path, handlers, accessToken, data) {
90 | if (data) {
91 | return axios[method](
92 | `${API_BASE_URL}${path}`,
93 | data,
94 | { headers: { Authorization: `Bearer ${accessToken}` } }
95 | );
96 | }
97 |
98 | return axios[method](
99 | `${API_BASE_URL}${path}`,
100 | { headers: { Authorization: `Bearer ${accessToken}` } }
101 | );
102 | }
103 |
104 | // gets a shared project (no authentication required for this)
105 | export function getSharedProject(id, handlers) {
106 | axios.get(`${API_BASE_URL}project/shared/${id}`)
107 | .then(res => { handlers.success(res); })
108 | .catch(err => { handlers.failure(err); });
109 | }
110 |
--------------------------------------------------------------------------------
/docker-compose-web.yml:
--------------------------------------------------------------------------------
1 | # this is to run the client development server against the deployed production server
2 |
3 | version: '3'
4 |
5 | services:
6 | client:
7 | container_name: bb-client
8 | build: ./client
9 | ports:
10 | - 3000:3000
11 | volumes:
12 | - ./client:/usr/src/app
13 | environment:
14 | NODE_ENV: production
15 | REACT_APP_API_URL: https://beat-bucket.herokuapp.com/
16 | REACT_APP_WEB_URL: http://localhost:3000/
17 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | # postgres db -- persists to local volume
5 | postgres:
6 | container_name: bb-db
7 | ports:
8 | - 5400:5432
9 | volumes:
10 | # add-hoc transfers
11 | - ~/code/.docker-volumes/host:/host
12 | # postgres data
13 | - ~/code/.docker-volumes/postgres/beatbucket:/var/lib/postgresql/data
14 | image: postgres
15 | environment:
16 | POSTGRES_PASSWORD: password
17 | PGDATA: /var/lib/postgresql/data/pgdata
18 |
19 | server:
20 | container_name: bb-server
21 | build: ./server
22 | ports:
23 | - 5000:5000
24 | volumes:
25 | - ./server:/usr/src/app
26 | environment:
27 | APP_SETTINGS: api.config.DevConfig
28 | SECRET_KEY: development_key
29 | DATABASE_URL: postgresql://postgres:password@bb-db:5432/beatbucket
30 | DATABASE_TEST_URL: postgresql://postgres:password@bb-db:5432/beatbucket_test
31 | depends_on:
32 | - postgres
33 | links:
34 | - postgres
35 |
36 | client:
37 | container_name: bb-client
38 | build: ./client
39 | ports:
40 | - 3000:3000
41 | volumes:
42 | - ./client:/usr/src/app
43 | depends_on:
44 | - server
45 | links:
46 | - server
47 |
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.6.1
2 |
3 | # install pipenv
4 | RUN pip install pipenv
5 |
6 | # set working directory
7 | WORKDIR /usr/src/app
8 |
9 | # add Pipfile
10 | COPY ./Pipfile* ./
11 |
12 | # install dependencies
13 | RUN pipenv install
14 |
15 | # add app
16 | COPY . .
17 |
18 | EXPOSE 5000
19 |
20 | # run server
21 | CMD ["pipenv", "run", "python", "run.py"]
22 |
23 |
--------------------------------------------------------------------------------
/server/Pipfile:
--------------------------------------------------------------------------------
1 | [[source]]
2 |
3 | verify_ssl = true
4 | name = "pypi"
5 | url = "https://pypi.python.org/simple"
6 |
7 |
8 | [packages]
9 |
10 | flask = "*"
11 | "psycopg2" = "*"
12 | pytest = "*"
13 | bcrypt = "*"
14 | email-validator = "*"
15 | pyjwt = "*"
16 | flask-cors = "*"
17 | gunicorn = "*"
18 |
19 |
20 | [dev-packages]
21 |
22 |
--------------------------------------------------------------------------------
/server/api/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 | from flask import Flask, g, jsonify
3 | from flask_cors import CORS
4 | from api.views.auth import auth_bp
5 | from api.views.resource import resource_bp
6 |
7 |
8 | def create_app(conf_obj_path):
9 | '''app factory'''
10 | app = Flask(__name__, static_folder='../dist', template_folder='../dist')
11 | CORS(app)
12 |
13 | app.config.from_object(conf_obj_path)
14 | app.register_blueprint(auth_bp)
15 | app.register_blueprint(resource_bp)
16 |
17 | @app.errorhandler(404)
18 | def page_not_found(e):
19 | return jsonify({'error': '404: Page not found.'}), 404
20 |
21 | @app.teardown_appcontext
22 | def teardown_db(exception):
23 | '''Tears down the database connection after a request'''
24 | db = getattr(g, '_database', None)
25 | if db is not None:
26 | db.close()
27 |
28 | return app
29 |
--------------------------------------------------------------------------------
/server/api/auth.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import jwt
3 |
4 |
5 | def encode_auth_token(token_type, user_id, expiry_time, secret_key):
6 | '''Takes a user id and generates an auth token'''
7 | now = datetime.datetime.utcnow()
8 | payload = {
9 | 'exp': now + expiry_time,
10 | 'iat': now,
11 | 'sub': user_id,
12 | 'type': token_type
13 | }
14 | return jwt.encode(
15 | payload,
16 | secret_key,
17 | algorithm='HS256'
18 | )
19 |
20 |
21 | def decode_auth_token(auth_token, secret_key):
22 | '''Decodes the auth token'''
23 | try:
24 | payload = jwt.decode(
25 | auth_token,
26 | secret_key,
27 | algorithms=['HS256']
28 | )
29 | return payload
30 | except jwt.ExpiredSignatureError:
31 | return None
32 | except jwt.InvalidTokenError:
33 | return None
34 |
35 |
36 | def get_token(headers):
37 | '''Gets the auth token from the headers if it exists'''
38 | auth_header = headers.get('Authorization')
39 | if auth_header:
40 | return auth_header.split(' ')[1]
41 | return ''
42 |
43 | # does this need to return both the token and the id?
44 | def get_data_from_token(headers, secret_key):
45 | '''
46 | Tries to get the user_id from the auth token in the headers
47 | If it fails, it returns a dict with 'error' and 'status_code' keys
48 | '''
49 | auth_token = get_token(headers)
50 | # auth_token = get_token(headers)
51 | if not auth_token:
52 | return {'error': 'No authentication provided', 'status_code': 401}
53 |
54 | token_data = decode_auth_token(auth_token, secret_key)
55 |
56 | if token_data is None:
57 | return {'error': 'Invalid token', 'status_code': 401}
58 |
59 | return token_data
60 |
--------------------------------------------------------------------------------
/server/api/config.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | class BaseConfig:
4 | ''' base configuration '''
5 | DEBUG = False
6 | TESTING = False
7 | DB_URL = os.getenv('DATABASE_URL')
8 | SECRET_KEY = os.getenv('SECRET_KEY', 'shazaam')
9 |
10 |
11 | class DevConfig(BaseConfig):
12 | ''' development configuration '''
13 | DEBUG = True
14 |
15 |
16 | class TestConfig(BaseConfig):
17 | ''' testing configuration '''
18 | DEBUG = True
19 | TESTING = True
20 | DB_URL = os.getenv('DATABASE_TEST_URL')
21 |
22 |
23 | class ProdConfig(BaseConfig):
24 | ''' production config '''
25 | DEBUG = False
26 |
--------------------------------------------------------------------------------
/server/api/db/__init__.py:
--------------------------------------------------------------------------------
1 | import psycopg2
2 |
3 | from api.db.auth import (get_user_by_email, add_user, create_hashed_user, insert_user,
4 | email_exists, get_user_by_id)
5 | from api.db.project import (get_all_projects, get_project_id, get_project, insert_project,
6 | update_project, delete_project)
7 | from api.db.track import insert_track, update_track, delete_track
8 |
9 |
10 | def get_db(app, g):
11 | '''gets the db'''
12 | db = getattr(g, '_database', None)
13 | if db is None:
14 | db = g._database = connect_to_db(app)
15 | return db
16 |
17 |
18 | def connect_to_db(app):
19 | '''Initializes db connection'''
20 | return psycopg2.connect(app.config['DB_URL'])
21 |
--------------------------------------------------------------------------------
/server/api/db/auth.py:
--------------------------------------------------------------------------------
1 | import json
2 | import psycopg2
3 | import psycopg2.extras
4 | import bcrypt
5 |
6 | def get_user_by_id(conn, user_id):
7 | '''Retrieves user data by id'''
8 | cursor = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
9 | cursor.execute(
10 | '''
11 | SELECT * FROM users
12 | WHERE id = %s
13 | ''',
14 | (user_id,)
15 | )
16 | result = cursor.fetchone()
17 | cursor.close()
18 | return result
19 |
20 |
21 | def get_user_by_email(conn, email):
22 | '''Retrieves user data by email'''
23 | cursor = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
24 | cursor.execute(
25 | '''
26 | SELECT * FROM users
27 | WHERE email = %s
28 | ''',
29 | (email,)
30 | )
31 | result = cursor.fetchone()
32 | cursor.close()
33 | return result
34 |
35 |
36 | def add_user(conn, user_dict):
37 | '''Add a user to the database'''
38 | cursor = conn.cursor()
39 | user_data = create_hashed_user(user_dict)
40 | insert_user(cursor, user_data)
41 | conn.commit()
42 | cursor.close()
43 |
44 |
45 | def create_hashed_user(user_dict):
46 | '''
47 | Takes a user dict {username, email, password}
48 | and returns a fresh dict {username, email, password, salt}
49 | in which the password is hashed with the salt
50 | '''
51 | salt = bcrypt.gensalt()
52 | pword = bcrypt.hashpw(user_dict['password'].encode('utf-8'), salt)
53 | hashed_user = dict(user_dict)
54 | hashed_user['password'] = pword
55 | hashed_user['salt'] = salt
56 | return hashed_user
57 |
58 |
59 | def insert_user(cursor, hashed_user_dict):
60 | '''
61 | Inserts a dict {username, email, password, salt} with hashed password
62 | into the users table
63 | '''
64 | cursor.execute(
65 | '''
66 | INSERT INTO users (email, password, salt)
67 | VALUES (%(email)s, %(password)s, %(salt)s)
68 | ''',
69 | hashed_user_dict
70 | )
71 |
72 |
73 | def email_exists(conn, email):
74 | '''Checks if an email already exists in the database'''
75 | cursor = conn.cursor()
76 | cursor.execute(
77 | '''
78 | SELECT email FROM users
79 | WHERE email = %s
80 | ''',
81 | (email,)
82 | )
83 | result = cursor.fetchone()
84 | cursor.close()
85 | if result is not None:
86 | return True
87 | return False
88 |
--------------------------------------------------------------------------------
/server/api/db/init_db.py:
--------------------------------------------------------------------------------
1 | ''' Initialize the db tables: `users` and `projects` '''
2 |
3 | import os
4 | import psycopg2
5 |
6 |
7 | def drop_users(cursor):
8 | '''Drops the users table'''
9 | cursor.execute('DROP TABLE IF EXISTS users')
10 |
11 |
12 | def create_users(cursor):
13 | '''Creates the users table'''
14 | cursor.execute(
15 | '''
16 | CREATE TABLE users (
17 | id SERIAL PRIMARY KEY,
18 | email VARCHAR(50) NOT NULL UNIQUE,
19 | password BYTEA NOT NULL,
20 | salt BYTEA NOT NULL
21 | )
22 | '''
23 | )
24 |
25 |
26 | def drop_projects(cursor):
27 | '''Drops the projects table'''
28 | cursor.execute('DROP TABLE IF EXISTS projects')
29 |
30 |
31 | def create_projects(cursor):
32 | '''Creates the projects table'''
33 | cursor.execute(
34 | '''
35 | CREATE TABLE projects (
36 | id SERIAL PRIMARY KEY,
37 | name VARCHAR(100) NOT NULL,
38 | user_id INTEGER NOT NULL REFERENCES users,
39 | data JSON,
40 | shared BOOLEAN NOT NULL
41 | )
42 | '''
43 | )
44 |
45 |
46 | def recreate_all():
47 | '''recreates all the tables'''
48 | main_db_url = os.getenv('DATABASE_URL', None)
49 | main_conn = psycopg2.connect(main_db_url)
50 | main_cur = main_conn.cursor()
51 | drop_projects(main_cur)
52 | drop_users(main_cur)
53 | create_users(main_cur)
54 | create_projects(main_cur)
55 | main_conn.commit()
56 | main_cur.close()
57 | main_conn.close()
58 |
59 | test_db_url = os.getenv('DATABASE_TEST_URL', None)
60 | if test_db_url is not None:
61 | test_conn = psycopg2.connect(test_db_url)
62 | test_cur = test_conn.cursor()
63 | drop_projects(test_cur)
64 | drop_users(test_cur)
65 | create_users(test_cur)
66 | create_projects(test_cur)
67 | test_conn.commit()
68 | test_cur.close()
69 | test_conn.close()
70 |
71 |
72 | if __name__ == '__main__':
73 | recreate_all()
74 |
--------------------------------------------------------------------------------
/server/api/db/project.py:
--------------------------------------------------------------------------------
1 | import json
2 | import psycopg2
3 | import psycopg2.extras
4 |
5 | def get_all_projects(cursor, user_id):
6 | '''Gets all the projects (name, id) of a given user'''
7 | cursor.execute(
8 | '''
9 | SELECT id, name FROM projects
10 | WHERE user_id = %s
11 | ''',
12 | (user_id,)
13 | )
14 | result = cursor.fetchall()
15 | return result
16 |
17 |
18 | def get_project_id(conn, user_id, name):
19 | '''Gets the id of a project by user id and project name'''
20 | cursor = conn.cursor()
21 | cursor.execute(
22 | '''
23 | SELECT id FROM projects
24 | WHERE user_id = %s and name = %s
25 | ''',
26 | (user_id, name)
27 | )
28 | result = cursor.fetchone()
29 | cursor.close()
30 | return result
31 |
32 |
33 | def get_project(conn, project_id):
34 | '''Gets project data'''
35 | cursor = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
36 | cursor.execute(
37 | '''
38 | SELECT * FROM projects
39 | WHERE id = %s
40 | ''',
41 | (project_id,)
42 | )
43 |
44 | # try to convert it to a dict. if it can't be converted, that means the project doesn't exist
45 | try:
46 | project = dict(cursor.fetchone())
47 | except TypeError:
48 | cursor.close()
49 | return None
50 |
51 | cursor.close()
52 | return project
53 |
54 |
55 | def insert_project(conn, project_dict):
56 | '''Inserts a new project into the database'''
57 | cursor = conn.cursor()
58 | cursor.execute(
59 | '''
60 | INSERT INTO projects (name, user_id, shared, data)
61 | VALUES (%(name)s, %(user_id)s, %(shared)s, %(data)s)
62 | ''',
63 | project_dict
64 | )
65 | cursor.close()
66 |
67 |
68 | def update_project(conn, project_dict):
69 | ''' Updates project data '''
70 | cursor = conn.cursor()
71 | cursor.execute(
72 | '''
73 | UPDATE projects
74 | SET name = %(name)s,
75 | shared = %(shared)s,
76 | data = %(data)s
77 | WHERE id = %(id)s
78 | ''',
79 | project_dict
80 | )
81 | cursor.close()
82 |
83 |
84 |
85 | def delete_project(conn, project_id):
86 | '''Deletes the given project'''
87 | cursor = conn.cursor()
88 | cursor.execute(
89 | '''
90 | DELETE FROM projects
91 | WHERE id = %s
92 | ''',
93 | (project_id,)
94 | )
95 | cursor.close()
96 |
--------------------------------------------------------------------------------
/server/api/db/track.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 |
4 | def insert_track(cursor, track_dict):
5 | '''Inserts a track into the database'''
6 | track_dict['sequence'] = json.dumps(track_dict['sequence'])
7 |
8 | cursor.execute(
9 | '''
10 | INSERT INTO tracks (
11 | name,
12 | base_note,
13 | muted,
14 | soloed,
15 | sequence,
16 | project_id,
17 | volume,
18 | next_id
19 | )
20 | VALUES (
21 | %(name)s,
22 | %(baseNote)s,
23 | %(muted)s,
24 | %(soloed)s,
25 | %(sequence)s,
26 | %(project_id)s,
27 | %(volume)s,
28 | %(nextId)s
29 | )
30 | ''',
31 | track_dict
32 | )
33 |
34 |
35 | def update_track(cursor, track_dict):
36 | '''Updates a track entry in the database'''
37 | if 'name' in track_dict:
38 | cursor.execute(
39 | '''
40 | UPDATE tracks
41 | SET name = %(name)s
42 | WHERE id = %(id)s
43 | ''',
44 | track_dict
45 | )
46 |
47 | if 'baseNote' in track_dict:
48 | cursor.execute(
49 | '''
50 | UPDATE tracks
51 | SET base_note = %(baseNote)s
52 | WHERE id = %(id)s
53 | ''',
54 | track_dict
55 | )
56 |
57 | if 'muted' in track_dict:
58 | cursor.execute(
59 | '''
60 | UPDATE tracks
61 | SET muted = %(muted)s
62 | WHERE id = %(id)s
63 | ''',
64 | track_dict
65 | )
66 |
67 | if 'soloed' in track_dict:
68 | cursor.execute(
69 | '''
70 | UPDATE tracks
71 | SET soloed = %(soloed)s
72 | WHERE id = %(id)s
73 | ''',
74 | track_dict
75 | )
76 |
77 | if 'sequence' in track_dict:
78 | track_dict['sequence'] = json.dumps(track_dict['sequence'])
79 | cursor.execute(
80 | '''
81 | UPDATE tracks
82 | SET sequence = %(sequence)s
83 | WHERE id = %(id)s
84 | ''',
85 | track_dict
86 | )
87 |
88 |
89 | def delete_track(cursor, track_id):
90 | '''Deletes a track'''
91 | cursor.execute(
92 | '''
93 | DELETE FROM tracks
94 | WHERE id = %s
95 | ''',
96 | (track_id,)
97 | )
98 |
--------------------------------------------------------------------------------
/server/api/views/auth.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from flask import Blueprint, current_app, g, jsonify, request
3 | import bcrypt
4 | from email_validator import validate_email, EmailNotValidError
5 | from api.db import (get_db, connect_to_db, add_user, email_exists, get_user_by_email,
6 | get_user_by_id)
7 | from api.auth import (encode_auth_token, decode_auth_token, get_token,
8 | get_data_from_token)
9 |
10 |
11 | auth_bp = Blueprint('auth_bp', __name__)
12 |
13 | @auth_bp.route('/auth/register', methods=['POST'])
14 | def register():
15 | '''Register a new user'''
16 | json_data = request.get_json()
17 | if not json_data:
18 | return jsonify({'error': 'Request body must be json.'}), 400
19 |
20 | if not all(k in json_data for k in ('email', 'password')):
21 | return jsonify({'error': 'Request must contain email and password'}), 400
22 |
23 | if len(json_data['password']) < 6:
24 | return jsonify({'error': 'Password must be at least six characters'}), 400
25 |
26 | try:
27 | v_email = validate_email(json_data['email'])
28 | email = v_email['email']
29 | except EmailNotValidError as err:
30 | return jsonify({'error': str(err)}), 400
31 |
32 | db_conn = get_db(current_app, g)
33 |
34 | if email_exists(db_conn, email):
35 | return jsonify({'error': 'A user with that email already exists'}), 400
36 |
37 | json_data['email'] = email
38 |
39 | add_user(db_conn, json_data)
40 | return jsonify({'message': 'Success', 'email': json_data['email']}), 201
41 |
42 |
43 | @auth_bp.route('/auth/login', methods=['POST'])
44 | def login():
45 | '''Log a user in'''
46 | json_data = request.get_json()
47 | if not json_data:
48 | return jsonify({'error': 'Request body must be json.'}), 400
49 |
50 | if not all(k in json_data for k in ('email', 'password')):
51 | return jsonify({'error': 'Login request must have email and password'}), 400
52 |
53 | db_conn = get_db(current_app, g)
54 |
55 | result = get_user_by_email(db_conn, json_data['email'])
56 |
57 | # check that user exists
58 | if result is None:
59 | return jsonify({'error': 'Invalid email address or password'}), 400
60 |
61 | # verify password
62 | salt = bytes(result['salt'])
63 | db_pass = bytes(result['password'])
64 | req_pass = bcrypt.hashpw(json_data['password'].encode('utf-8'), salt)
65 | if req_pass != db_pass:
66 | return jsonify({'error': 'Invalid email address or password'}), 400
67 |
68 | # generate jwt
69 | access_token = encode_auth_token(
70 | 'access',
71 | result['id'],
72 | datetime.timedelta(minutes=20),
73 | current_app.config['SECRET_KEY']
74 | )
75 |
76 | refresh_token = encode_auth_token(
77 | 'refresh',
78 | result['id'],
79 | datetime.timedelta(days=5),
80 | current_app.config['SECRET_KEY']
81 | )
82 |
83 | return jsonify({
84 | 'message': 'Success',
85 | 'email': result['email'],
86 | 'userId': result['id'],
87 | 'accessToken': access_token.decode(),
88 | 'refreshToken': refresh_token.decode()
89 | }), 200
90 |
91 |
92 | @auth_bp.route('/auth/authenticate', methods=['GET'])
93 | def authenticate():
94 | ''' Verifies a refresh token and generates an access token '''
95 | token_data = get_data_from_token(request.headers, current_app.config['SECRET_KEY'])
96 | if 'error' in token_data:
97 | return jsonify({'error': token_data['error']}), token_data['status_code']
98 |
99 | if token_data['type'] != 'refresh':
100 | return jsonify({'error': 'Invalid token type'}), 401
101 |
102 | db_conn = get_db(current_app, g)
103 | user = get_user_by_id(db_conn, token_data['sub'])
104 |
105 |
106 | # create new access token
107 | access_token = encode_auth_token(
108 | 'access',
109 | token_data['sub'],
110 | datetime.timedelta(minutes=20),
111 | current_app.config['SECRET_KEY']
112 | )
113 |
114 | response_data = {
115 | 'message': 'Success',
116 | 'userId': token_data['sub'],
117 | 'email': user['email'],
118 | 'accessToken': access_token.decode()
119 | }
120 |
121 | print(token_data['exp'])
122 | print(datetime.datetime.utcnow())
123 |
124 | # create a new refresh token if the current one is about to expire
125 | expiry_time = datetime.datetime.fromtimestamp(token_data['exp'])
126 | time_til_expiry = expiry_time - datetime.datetime.utcnow()
127 | if time_til_expiry <= datetime.timedelta(minutes=30):
128 | refresh_token = encode_auth_token(
129 | 'refresh',
130 | token_data['sub'],
131 | datetime.timedelta(days=5),
132 | current_app.config['SECRET_KEY']
133 | )
134 | response_data['refreshToken'] = refresh_token.decode()
135 |
136 | return jsonify(response_data)
137 |
--------------------------------------------------------------------------------
/server/api/views/resource.py:
--------------------------------------------------------------------------------
1 | import json
2 | import psycopg2.extras
3 | from flask import Blueprint, current_app, g, jsonify, request
4 | from api.db import (get_db, insert_project, get_project_id, insert_track, get_all_projects,
5 | get_project, update_project, update_track, delete_track,
6 | delete_project)
7 | from api.auth import decode_auth_token, get_token, get_data_from_token
8 |
9 |
10 | resource_bp = Blueprint('resource_bp', __name__)
11 |
12 |
13 | @resource_bp.route('/projects', methods=['GET'])
14 | def projects_get():
15 | '''Gets all projects for a given user'''
16 | token_data = get_data_from_token(request.headers, current_app.config['SECRET_KEY'])
17 | if 'error' in token_data:
18 | return jsonify({'error': token_data['error']}), token_data['status_code']
19 |
20 | if token_data['type'] != 'access':
21 | return jsonify({'error': 'Invalid token type'}), 401
22 |
23 | db_conn = get_db(current_app, g)
24 | cursor = db_conn.cursor()
25 | projects = get_all_projects(cursor, token_data['sub'])
26 | cursor.close()
27 | return jsonify({'message': 'Success', 'projects': projects}), 200
28 |
29 |
30 | @resource_bp.route('/project/', methods=['GET'])
31 | def project_get(project_id):
32 | '''Gets a project from the database'''
33 | token_data = get_data_from_token(request.headers, current_app.config['SECRET_KEY'])
34 | if 'error' in token_data:
35 | return jsonify({'error': token_data['error']}), token_data['status_code']
36 |
37 | if token_data['type'] != 'access':
38 | return jsonify({'error': 'Invalid token type'}), 401
39 |
40 | db_conn = get_db(current_app, g)
41 | project = get_project(db_conn, project_id)
42 |
43 | if project is None:
44 | return jsonify({'error': 'Project does not exist'}), 400
45 |
46 | if project['user_id'] != token_data['sub']:
47 | return jsonify({'error': 'Forbidden: project belongs to another user'}), 403
48 |
49 | return jsonify({
50 | 'message': 'Success',
51 | 'project': project['data'],
52 | 'id': project['id']
53 | })
54 |
55 |
56 | @resource_bp.route('/project/', methods=['DELETE'])
57 | def project_delete(project_id):
58 | '''Deletes a project'''
59 | token_data = get_data_from_token(request.headers, current_app.config['SECRET_KEY'])
60 | if 'error' in token_data:
61 | return jsonify({'error': token_data['error']}), token_data['status_code']
62 |
63 | if token_data['type'] != 'access':
64 | return jsonify({'error': 'Invalid token type'}), 401
65 |
66 | db_conn = get_db(current_app, g)
67 | project = get_project(db_conn, project_id)
68 |
69 | if project is None:
70 | return jsonify({'error': 'Project does not exist'}), 400
71 |
72 | if project['user_id'] != token_data['sub']:
73 | return jsonify({'error': 'Forbidden: project belongs to another user'}), 403
74 |
75 | delete_project(db_conn, project_id)
76 | db_conn.commit()
77 |
78 | return jsonify({'message': 'Success'}), 200
79 |
80 |
81 | @resource_bp.route('/project/shared/', methods=['GET'])
82 | def get_shared_project(project_id):
83 | ''' Gets a shared project '''
84 | db_conn = get_db(current_app, g)
85 | project = get_project(db_conn, project_id)
86 |
87 | if project is None:
88 | return jsonify({'error': 'Project does not exist'}), 404
89 |
90 | if not project['shared']:
91 | return jsonify({'error': 'Forbidden: Project is private'}), 403
92 |
93 | project['data'].pop('id', None)
94 |
95 | return jsonify({
96 | 'message': 'Success',
97 | 'project': project['data']
98 | })
99 |
100 |
101 | @resource_bp.route('/save', methods=['POST'])
102 | def save_project():
103 | '''Saves a brand new project'''
104 | token_data = get_data_from_token(request.headers, current_app.config['SECRET_KEY'])
105 | if 'error' in token_data:
106 | return jsonify({'error': token_data['error']}), token_data['status_code']
107 |
108 | if token_data['type'] != 'access':
109 | return jsonify({'error': 'Invalid token type'}), 401
110 |
111 | json_data = request.get_json()
112 | db_conn = get_db(current_app, g)
113 |
114 | # return an error if the user already has a project with that name
115 | if get_project_id(db_conn, token_data['sub'], json_data['name']):
116 | return jsonify({'error': 'A project with that name already exists'}), 400
117 |
118 | project = {
119 | 'name': json_data['name'],
120 | 'user_id': token_data['sub'],
121 | 'shared': json_data['shared'],
122 | 'data': json.dumps(json_data)
123 | }
124 |
125 | insert_project(db_conn, project)
126 |
127 | project_id = get_project_id(db_conn, token_data['sub'], json_data['name'])[0]
128 |
129 | db_conn.commit()
130 |
131 | return jsonify({
132 | 'message': 'Success',
133 | 'projectId': project_id
134 | }), 201
135 |
136 |
137 | @resource_bp.route('/save', methods=['PUT'])
138 | def project_update():
139 | '''Makes changes to an existing project'''
140 | token_data = get_data_from_token(request.headers, current_app.config['SECRET_KEY'])
141 | if 'error' in token_data:
142 | return jsonify({'error': token_data['error']}), token_data['status_code']
143 |
144 | if token_data['type'] != 'access':
145 | return jsonify({'error': 'Invalid token type'}), 401
146 |
147 | json_data = request.get_json()
148 | db_conn = get_db(current_app, g)
149 | project = get_project(db_conn, json_data['id'])
150 |
151 | if project is None:
152 | return jsonify({'error': 'Project does not exist'}), 404
153 |
154 | if token_data['sub'] != project['user_id']:
155 | return jsonify({'error': 'Forbidden: project belongs to another user'}), 403
156 |
157 | updated_project = {
158 | 'id': json_data['id'],
159 | 'name': json_data['name'],
160 | 'shared': json_data['shared'],
161 | 'data': json.dumps(json_data)
162 | }
163 |
164 | update_project(db_conn, updated_project)
165 |
166 | db_conn.commit()
167 |
168 | return jsonify({'message': 'Success'}), 200
169 |
--------------------------------------------------------------------------------
/server/run.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3.6
2 | import os
3 |
4 | from api import create_app
5 | app = create_app(os.getenv('APP_SETTINGS', 'api.config.DevConfig'))
6 |
7 | if __name__ == '__main__':
8 | app.run(host='0.0.0.0')
9 |
--------------------------------------------------------------------------------
/server/tests/dummy_data.py:
--------------------------------------------------------------------------------
1 | def generate_temp_project():
2 | return {
3 | 'bpm': 120,
4 | 'name': 'My New Project',
5 | 'shared': False,
6 | 'tracks': [
7 | {
8 | 'name': 'Track 1',
9 | 'baseNote': 1,
10 | 'muted': False,
11 | 'soloed': False,
12 | 'sequence': [['C4'], ['D4', 'E4'], ['F4'], ['G4', 'G4', 'G4']],
13 | 'volume': 0,
14 | 'nextId': 100
15 | },
16 | {
17 | 'name': 'Track 2',
18 | 'baseNote': 2,
19 | 'muted': True,
20 | 'soloed': False,
21 | 'sequence': [['C4', 'G4']],
22 | 'volume': 0,
23 | 'nextId': 20
24 | }
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/server/tests/fixtures.py:
--------------------------------------------------------------------------------
1 | ''' Pytest Fixtures '''
2 |
3 | import json
4 | from pytest import fixture
5 | from api import create_app
6 | from api.db import create_hashed_user, connect_to_db
7 | from dummy_data import generate_temp_project
8 |
9 |
10 | @fixture
11 | def temp_app():
12 | '''Sets up the test app and populates the test database.'''
13 | return create_app('api.config.TestConfig').test_client()
14 |
15 |
16 | @fixture
17 | def temp_db(temp_app):
18 | '''Populates the db with dummy data and yields the db connection'''
19 | user_a = create_hashed_user({
20 | 'email': 'hello@goodbye.com',
21 | 'password': 'goodbye'
22 | })
23 | user_a['id'] = 1
24 |
25 | user_b = create_hashed_user({
26 | 'email': 'bmackland@fbi.net',
27 | 'password': 'mackland'
28 | })
29 | user_b['id'] = 2
30 |
31 | # db_conn = connect_to_db('beatbucket_test')
32 | db_conn = connect_to_db(temp_app.application)
33 | cursor = db_conn.cursor()
34 |
35 | cursor.execute(
36 | '''
37 | INSERT INTO users (id, email, password, salt)
38 | VALUES (%(id)s, %(email)s, %(password)s, %(salt)s)
39 | ''',
40 | user_a
41 | )
42 | cursor.execute(
43 | '''
44 | INSERT INTO users (id, email, password, salt)
45 | VALUES (%(id)s, %(email)s, %(password)s, %(salt)s)
46 | ''',
47 | user_b
48 | )
49 |
50 | project_data = generate_temp_project()
51 | project_data['name'] = 'New Project 1'
52 | json_data = json.dumps(project_data)
53 | project = {
54 | 'name': 'New Project 1',
55 | 'id': 1,
56 | 'user_id': 1,
57 | 'data': json_data,
58 | 'shared': False
59 | }
60 |
61 | cursor.execute(
62 | '''
63 | INSERT INTO projects (id, name, user_id, data, shared)
64 | VALUES (%(id)s, %(name)s, %(user_id)s, %(data)s, %(shared)s)
65 | ''',
66 | project
67 | )
68 |
69 | project['shared'] = True
70 | project['name'] = 'Test Project'
71 | project['id'] = 2
72 | project_data['shared'] = True
73 | project_data['name'] = 'Test Project'
74 | project_data['id'] = 2
75 | project['data'] = json.dumps(project_data)
76 |
77 | cursor.execute(
78 | '''
79 | INSERT INTO projects (id, name, user_id, data, shared)
80 | VALUES (%(id)s, %(name)s, %(user_id)s, %(data)s, %(shared)s)
81 | ''',
82 | project
83 | )
84 |
85 | db_conn.commit()
86 | cursor.close()
87 |
88 | yield db_conn
89 |
90 | cursor = db_conn.cursor()
91 | cursor.execute('DELETE FROM projects')
92 | cursor.execute('DELETE FROM users')
93 | db_conn.commit()
94 | cursor.close()
95 | db_conn.close()
96 |
--------------------------------------------------------------------------------
/server/tests/test_auth.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import jwt
3 | from api.auth import encode_auth_token, decode_auth_token
4 |
5 |
6 | def test_encode_auth_token():
7 | '''Tests that a token can be encoded'''
8 | token = encode_auth_token('access', 134, datetime.timedelta(days=1), 'shazaam')
9 | assert isinstance(token, bytes)
10 |
11 |
12 | def test_decode_auth_token():
13 | '''Tests that a token can be decoded'''
14 | token = encode_auth_token('access', 123, datetime.timedelta(days=1), 'shazaam')
15 | res = decode_auth_token(token, 'shazaam')
16 | keys = ['sub', 'exp', 'iat', 'type']
17 | assert set(keys) == set(res)
18 | assert res['sub'] == 123
19 |
20 |
21 | def test_decode_token_wrong_key():
22 | '''Tests that a token can't be decoded with the wrong key'''
23 | token = encode_auth_token('access', 123, datetime.timedelta(days=1), 'shazaam')
24 | res = decode_auth_token(token, 'kazaam')
25 | assert res is None
26 |
27 |
28 | def test_decode_expired_token():
29 | '''Tests that an expired token can't be decoded'''
30 | now = datetime.datetime.utcnow()
31 | payload = {
32 | 'exp': now - datetime.timedelta(seconds=30),
33 | 'iat': now - datetime.timedelta(minutes=1),
34 | 'sub': 123
35 | }
36 | token = jwt.encode(
37 | payload,
38 | 'shazaam',
39 | algorithm='HS256'
40 | )
41 | res = decode_auth_token(token, 'shazaam')
42 | assert res is None
43 |
--------------------------------------------------------------------------------
/server/tests/test_authenticate.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import json
3 | import jwt
4 | from fixtures import temp_app, temp_db
5 | from utils import (login_hello, generate_expired_token, generate_invalid_token,
6 | get_authenticate)
7 | from api.auth import encode_auth_token
8 |
9 | def test_get_access_token(temp_app, temp_db):
10 | '''Tests verifying a valid jwt'''
11 | login_res = login_hello(temp_app)
12 | auth_token = login_res['refreshToken']
13 | res = get_authenticate(auth_token, temp_app)
14 | res_data = json.loads(res.data)
15 | assert res.status_code == 200
16 | assert set(['message', 'userId', 'email', 'accessToken']) == set(res_data)
17 | assert res_data['email'] == 'hello@goodbye.com'
18 |
19 |
20 | def test_get_new_refresh_token(temp_app, temp_db):
21 | '''Tests sending a refresh token that's about to expire, and getting a new one'''
22 | auth_token = encode_auth_token(
23 | 'refresh',
24 | 1,
25 | datetime.timedelta(minutes=30),
26 | temp_app.application.config['SECRET_KEY']
27 | )
28 | res = get_authenticate(auth_token.decode(), temp_app)
29 | res_data = json.loads(res.data)
30 | assert res.status_code == 200
31 | keys = ['message', 'userId', 'email', 'accessToken', 'refreshToken']
32 | assert set(keys) == set(res_data)
33 | assert res_data['email'] == 'hello@goodbye.com'
34 |
35 |
36 | def test_authenticate_fail(temp_app, temp_db):
37 | '''Tests various failure cases when verifying a jwt'''
38 |
39 | # Tests verifying with no auth header
40 | res = temp_app.get('auth/authenticate')
41 | res_data = json.loads(res.data)
42 | assert res.status_code == 401
43 | assert res_data['error'] == 'No authentication provided'
44 |
45 | # Tests verifying with no auth token
46 | res = get_authenticate('', temp_app)
47 | res_data = json.loads(res.data)
48 | assert res.status_code == 401
49 | assert res_data['error'] == 'No authentication provided'
50 |
51 | # Tests trying to verify with an expired token
52 | auth_token = generate_expired_token(
53 | 'refresh',
54 | temp_app.application.config['SECRET_KEY']
55 | )
56 | res = get_authenticate(auth_token, temp_app)
57 | res_data = json.loads(res.data)
58 | assert res.status_code == 401
59 | assert res_data['error'] == 'Invalid token'
60 |
61 | # Tests trying to verify with a token signed with the wrong key
62 | auth_token = generate_invalid_token('refresh')
63 | res = get_authenticate(auth_token, temp_app)
64 | res_data = json.loads(res.data)
65 | assert res.status_code == 401
66 | assert res_data['error'] == 'Invalid token'
67 |
68 | # Tests trying to use an access token to refresh
69 | token = encode_auth_token(
70 | 'access',
71 | 1,
72 | datetime.timedelta(days=3),
73 | temp_app.application.config['SECRET_KEY']
74 | )
75 | res = get_authenticate(token.decode(), temp_app)
76 | res_data = json.loads(res.data)
77 | assert res.status_code == 401
78 | assert res_data['error'] == 'Invalid token type'
79 |
--------------------------------------------------------------------------------
/server/tests/test_login.py:
--------------------------------------------------------------------------------
1 | import json
2 | from fixtures import temp_app, temp_db
3 | from utils import login
4 |
5 |
6 | def test_login_user(temp_app, temp_db):
7 | '''Tests logging in a user.'''
8 | user = {
9 | 'email': 'hello@goodbye.com',
10 | 'password': 'goodbye'
11 | }
12 | res = login(user, temp_app)
13 | res_data = json.loads(res.data)
14 | assert res.status_code == 200, 'Response code should be 200 - OK'
15 | assert isinstance(res_data, dict), 'Response data must be in json'
16 | assert res_data['message'] == 'Success', 'Message should read "Success"'
17 | assert 'email' in res_data, 'Reponse must have email key'
18 | assert 'userId' in res_data, 'Response must have user_id key'
19 | assert 'accessToken' in res_data, 'Response must have an access token'
20 | assert 'refreshToken' in res_data, 'Response must have a refresh token'
21 | assert isinstance(res_data['accessToken'], str)
22 | assert isinstance(res_data['refreshToken'], str)
23 |
24 |
25 | def test_login_fail(temp_app, temp_db):
26 | '''Tests various login failure cases'''
27 |
28 | # Tests trying to log in with no email
29 | res = login({'password': 'testpass'}, temp_app)
30 | res_data = json.loads(res.data)
31 | assert res.status_code == 400, 'Response code should be 400 - BAD REQUEST'
32 | assert isinstance(res_data, dict), 'Response data must be in json'
33 | assert res_data['error'] == 'Login request must have email and password'
34 |
35 | # Tests trying to log in with no password
36 | res = login({'email': 'hello@goodbye.com'}, temp_app)
37 | res_data = json.loads(res.data)
38 | assert res.status_code == 400, 'Response code should be 400 - BAD REQUEST'
39 | assert isinstance(res_data, dict), 'Response data must be in json'
40 | assert res_data['error'] == 'Login request must have email and password'
41 |
42 | # Tests trying to log in with the wrong password
43 | user = {
44 | 'email': 'hello@goodbye.com',
45 | 'password': 'hellothere'
46 | }
47 | res = login(user, temp_app)
48 | res_data = json.loads(res.data)
49 | assert res.status_code == 400, 'Response code should be 400 - BAD REQUEST'
50 | assert isinstance(res_data, dict), 'Response data must be in json'
51 | assert res_data['error'] == 'Invalid email address or password'
52 |
53 | # Tests trying to log in with the wrong email
54 | user = {
55 | 'email': 'fake@user.com',
56 | 'password': 'hellothere'
57 | }
58 | res = login(user, temp_app)
59 | res_data = json.loads(res.data)
60 | assert res.status_code == 400, 'Response code should be 400 - BAD REQUEST'
61 | assert isinstance(res_data, dict), 'Response data must be in json'
62 | assert res_data['error'] == 'Invalid email address or password'
63 |
--------------------------------------------------------------------------------
/server/tests/test_projects.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import json
3 | from fixtures import temp_app, temp_db
4 | from utils import (get_projects, login_hello, login_mackland, generate_expired_token,
5 | generate_invalid_token)
6 | from api.auth import encode_auth_token
7 |
8 |
9 | def test_get_projects(temp_app, temp_db):
10 | '''Tests getting all projects by an authenticated user'''
11 | login_res = login_hello(temp_app)
12 | auth_token = login_res['accessToken']
13 | res = get_projects(auth_token, temp_app)
14 | res_data = json.loads(res.data)
15 | assert res.status_code == 200
16 | assert res_data['message'] == 'Success'
17 | assert isinstance(res_data['projects'], list)
18 |
19 |
20 | def test_get_projects_none(temp_app, temp_db):
21 | '''Tests getting all projects by a user with no projects'''
22 | login_res = login_mackland(temp_app)
23 | auth_token = login_res['accessToken']
24 | res = get_projects(auth_token, temp_app)
25 | res_data = json.loads(res.data)
26 | assert res.status_code == 200
27 | assert res_data['message'] == 'Success'
28 | assert isinstance(res_data['projects'], list)
29 | assert not res_data['projects'], '"projects" should be an empty list here'
30 |
31 |
32 | def test_get_projects_fail(temp_app, temp_db):
33 | '''Tests getting projects with various failure cases'''
34 |
35 | # Tests trying to get projects with no auth header
36 | res = temp_app.get('/projects')
37 | res_data = json.loads(res.data)
38 | assert res.status_code == 401
39 | assert res_data['error'] == 'No authentication provided'
40 |
41 | # Tests trying to get projects with no auth header
42 | res = temp_app.get(
43 | '/projects',
44 | headers=dict(Authorization='Bearer ')
45 | )
46 | res_data = json.loads(res.data)
47 | assert res.status_code == 401
48 | assert res_data['error'] == 'No authentication provided'
49 |
50 | # Tests trying to get projects with an expired token
51 | token = generate_expired_token('access', temp_app.application.config['SECRET_KEY'])
52 | res = get_projects(token, temp_app)
53 | res_data = json.loads(res.data)
54 | assert res.status_code == 401
55 | assert res_data['error'] == 'Invalid token'
56 |
57 | # Tests trying to get projects with a token signed with the wrong key
58 | token = generate_invalid_token('access')
59 | res = get_projects(token, temp_app)
60 | res_data = json.loads(res.data)
61 | assert res.status_code == 401
62 | assert res_data['error'] == 'Invalid token'
63 |
64 | # Tests trying to use a refresh token to access projects
65 | token = encode_auth_token(
66 | 'refresh',
67 | 1,
68 | datetime.timedelta(days=3),
69 | temp_app.application.config['SECRET_KEY']
70 | )
71 | res = get_projects(token.decode(), temp_app)
72 | res_data = json.loads(res.data)
73 | assert res.status_code == 401
74 | assert res_data['error'] == 'Invalid token type'
75 |
--------------------------------------------------------------------------------
/server/tests/test_register.py:
--------------------------------------------------------------------------------
1 | import psycopg2.extras
2 | import json
3 | from fixtures import temp_app, temp_db
4 |
5 |
6 | def post_register(user, app):
7 | return app.post(
8 | '/auth/register',
9 | data=json.dumps(user),
10 | content_type='application/json'
11 | )
12 |
13 | def test_register_user(temp_app, temp_db):
14 | '''Tests registering a single user.'''
15 | user = {
16 | 'email': 'krampus@krampus.com',
17 | 'password': 'santa_claus'
18 | }
19 | res = post_register(user, temp_app)
20 | res_data = json.loads(res.data)
21 | assert res.status_code == 201, 'Response code must be 201 - CREATED'
22 | assert isinstance(res_data, dict), 'Response data must be json object'
23 | assert 'email' in res_data, 'Response must have email key'
24 | assert not 'password' in res_data, 'Response must not contain password'
25 | assert res_data['message'] == 'Success', 'Message should read "Success"'
26 |
27 | # ensure the user is in the database
28 | cursor = temp_db.cursor(cursor_factory=psycopg2.extras.DictCursor)
29 | cursor.execute('SELECT * FROM users WHERE email = %(email)s', user)
30 | db_user = cursor.fetchone()
31 | assert db_user['email'] == 'krampus@krampus.com'
32 | assert 'id' in db_user
33 | assert isinstance(db_user['salt'], object)
34 | assert isinstance(db_user['password'], object)
35 |
36 | cursor.close()
37 |
38 |
39 | def test_register_fail(temp_app, temp_db):
40 | '''Tests various registration failure cases'''
41 |
42 | # Tests adding a user with a pre-existing email
43 | user = {
44 | 'email': 'bmackland@fbi.net',
45 | 'password': 'freeze!',
46 | }
47 | res = post_register(user, temp_app)
48 | res_data = json.loads(res.data)
49 | assert res.status_code == 400, 'Response should be 400 - BAD REQUEST'
50 | assert isinstance(res_data, dict), 'Response data must be json object'
51 | assert res_data['error'] == 'A user with that email already exists'
52 |
53 | # Tests adding a user with no email
54 | user = {'password': 'freeze!'}
55 | res = post_register(user, temp_app)
56 | res_data = json.loads(res.data)
57 | assert res.status_code == 400, 'Response should be 400 - BAD REQUEST'
58 | assert isinstance(res_data, dict), 'Response data must be json object'
59 | assert res_data['error'] == 'Request must contain email and password'
60 |
61 | # Tests adding a user with no email
62 | user = {'email': 'cold@freeze.com'}
63 | res = post_register(user, temp_app)
64 | res_data = json.loads(res.data)
65 | assert res.status_code == 400, 'Response should be 400 - BAD REQUEST'
66 | assert isinstance(res_data, dict), 'Response data must be json object'
67 | assert res_data['error'] == 'Request must contain email and password'
68 |
69 | # Tests registering an account with an invalid email string
70 | user = {
71 | 'email': 'coldfreeze.com',
72 | 'password': 'testpass',
73 | }
74 | res = post_register(user, temp_app)
75 | res_data = json.loads(res.data)
76 | assert res.status_code == 400, 'Response should be 400 - BAD REQUEST'
77 | assert isinstance(res_data, dict), 'Response data must be json object'
78 | assert res_data['error'] == 'The email address is not valid. It must have exactly one @-sign.'
79 |
80 | # Tests submitting a password that's too short
81 | user = {
82 | 'email': 'user@coldfreeze.com',
83 | 'password': 'qqqqq',
84 | }
85 | res = post_register(user, temp_app)
86 | res_data = json.loads(res.data)
87 | assert res.status_code == 400, 'Response should be 400 - BAD REQUEST'
88 | assert isinstance(res_data, dict), 'Response data must be json object'
89 | assert res_data['error'] == 'Password must be at least six characters'
90 |
--------------------------------------------------------------------------------
/server/tests/test_testing.py:
--------------------------------------------------------------------------------
1 | from fixtures import temp_app
2 |
3 | def test_app_is_testing(temp_app):
4 | conf = temp_app.application.config
5 | assert conf['TESTING'] is True
6 | assert conf['SECRET_KEY'] != 'shazaam'
7 |
--------------------------------------------------------------------------------
/server/tests/utils.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import json
3 | import jwt
4 |
5 | def post_save(data, auth_token, app):
6 | '''Sends POST to /save endpoint'''
7 | return app.post(
8 | '/save',
9 | data=json.dumps(data),
10 | content_type='application/json',
11 | headers=dict(Authorization=f'Bearer {auth_token}'))
12 |
13 |
14 | def put_save(data, auth_token, app):
15 | '''Sends PUT to /save endpoint'''
16 | return app.put(
17 | '/save',
18 | data=json.dumps(data),
19 | content_type='application/json',
20 | headers=dict(Authorization=f'Bearer {auth_token}'))
21 |
22 |
23 | def get_project(project_id, auth_token, app):
24 | '''Sends GET to /project/:id: endpoint'''
25 | return app.get(
26 | f'/project/{project_id}',
27 | headers=dict(Authorization=f'Bearer {auth_token}'))
28 |
29 |
30 | def delete_project(project_id, auth_token, app):
31 | '''Sends DELETE to /project endpoint'''
32 | return app.delete(
33 | f'/project/{project_id}',
34 | headers=dict(Authorization=f'Bearer {auth_token}'))
35 |
36 |
37 | def get_projects(auth_token, app):
38 | '''Sends GET to /projects endpoint'''
39 | return app.get(
40 | '/projects',
41 | headers=dict(Authorization=f'Bearer {auth_token}'))
42 |
43 |
44 | def login(user, app):
45 | '''sends login request'''
46 | return app.post(
47 | '/auth/login',
48 | data=json.dumps(user),
49 | content_type='application/json')
50 |
51 |
52 | def login_hello(app):
53 | '''Logs in as hello@goodbye.com'''
54 | user = {'email': 'hello@goodbye.com', 'password': 'goodbye'}
55 | res = login(user, app)
56 | res_data = json.loads(res.data)
57 | return res_data
58 |
59 |
60 | def login_mackland(app):
61 | '''Logs in as bmackland@fbi.net'''
62 | user = {'email': 'bmackland@fbi.net', 'password': 'mackland'}
63 | res = login(user, app)
64 | res_data = json.loads(res.data)
65 | return res_data
66 |
67 |
68 | def get_authenticate(auth_token, app):
69 | '''Posts GET to /auth/authenticate'''
70 | return app.get(
71 | '/auth/authenticate',
72 | headers=dict(Authorization=f'Bearer {auth_token}'))
73 |
74 |
75 | def generate_expired_token(token_type, secret_key):
76 | '''Generates an expired jwt'''
77 | now = datetime.datetime.utcnow()
78 | token_payload = {
79 | 'exp': now - datetime.timedelta(seconds=30),
80 | 'iat': now - datetime.timedelta(minutes=1),
81 | 'sub': 1,
82 | 'type': token_type
83 | }
84 | return jwt.encode(
85 | token_payload,
86 | secret_key,
87 | algorithm='HS256'
88 | )
89 |
90 |
91 | def generate_invalid_token(token_type):
92 | '''Generates a token signed with the wrong key'''
93 | now = datetime.datetime.utcnow()
94 | token_payload = {
95 | 'exp': now + datetime.timedelta(minutes=30),
96 | 'iat': now,
97 | 'sub': 1,
98 | 'type': token_type
99 | }
100 | return jwt.encode(
101 | token_payload,
102 | 'kazaam!',
103 | algorithm='HS256'
104 | )
105 |
--------------------------------------------------------------------------------