├── .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 | ![beat bucket demo gif](https://i.imgur.com/UIhepDn.gif) 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 |
my projects:
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 |
82 |
83 |
84 | 85 | 86 |
87 | 88 |
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 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 28 | 29 | 31 | 33 | 35 | 37 | 38 | 39 | 40 | 42 | 66 | 67 | 76 | 79 | 85 | 86 | 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 half note; 36 | case 4: 37 | return '♩'; 38 | case 2: 39 | return '♪'; 40 | case 1: 41 | return sixteenth note; 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 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 28 | 29 | 31 | 33 | 35 | 37 | 38 | 39 | 40 | 42 | 66 | 67 | 73 | 82 | 85 | 91 | 92 | 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 |
98 | { this.emailInput = el; }} 104 | required 105 | message={errors.email} 106 | /> 107 | 116 | 125 | 131 | 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 |
59 | { this.textInput = input; }} 68 | /> 69 |
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 |
36 |
37 | Beat Bucket 38 |
39 |
40 | 41 | 47 | 48 | 54 | {this.state.showDropDown && 55 | 59 | } 60 |
61 |
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 |
10 | 15 |
{message}
16 |
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 |
13 |
14 | 15 |
16 | 17 |
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 |
10 | Made with ♥ at the 11 | 16 | Recurse Center 17 | 18 | | 19 | 24 | github 25 | 26 |
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 |
46 | { this.emailEl = el; }} 50 | onChange={this.handleInputChange('email')} 51 | required 52 | /> 53 | 59 | 65 | 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 | 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /client/src/components/svg/clipboard.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // source: https://feathericons.com/ 4 | export default ({ className }) => ( 5 | 17 | 18 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /client/src/components/svg/copy.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // source: https://feathericons.com/ 4 | export default ({ className }) => ( 5 | 17 | 18 | 19 | 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 | 14 | 20 | 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 | 15 | 19 | 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 | 15 | 20 | 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 | 17 | 18 | 19 | 20 | 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 | 14 | 20 | 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 | 16 | 17 | 18 | 19 | 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 | --------------------------------------------------------------------------------