diff --git a/lab-nathan/backend b/lab-nathan/backend new file mode 160000 index 0000000..1fc6e99 --- /dev/null +++ b/lab-nathan/backend @@ -0,0 +1 @@ +Subproject commit 1fc6e992ae12d84d6e3f8dfc67a18c9f2eca29ad diff --git a/lab-nathan/frontend/.babelrc b/lab-nathan/frontend/.babelrc new file mode 100644 index 0000000..cf6ae40 --- /dev/null +++ b/lab-nathan/frontend/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["es2015", "react"], + "plugins": ["transform-object-rest-spread"] +} \ No newline at end of file diff --git a/lab-nathan/frontend/.gitignore b/lab-nathan/frontend/.gitignore new file mode 100644 index 0000000..60a5626 --- /dev/null +++ b/lab-nathan/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules +coverage +build +.env \ No newline at end of file diff --git a/lab-nathan/frontend/package.json b/lab-nathan/frontend/package.json new file mode 100644 index 0000000..79b4cb9 --- /dev/null +++ b/lab-nathan/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "frontend", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "build": "webpack", + "watch": "webpack-dev-server --inline --hot", + "test": "jest" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "babel-core": "^6.26.0", + "babel-loader": "^7.1.2", + "babel-plugin-transform-object-rest-spread": "^6.26.0", + "babel-preset-es2015": "^6.24.1", + "babel-preset-react": "^6.24.1", + "clean-webpack-plugin": "^0.1.16", + "css-loader": "^0.28.7", + "dotenv": "^4.0.0", + "extract-text-webpack-plugin": "^3.0.0", + "file-loader": "^0.11.2", + "html-webpack-plugin": "^2.30.1", + "jest": "^21.0.1", + "node-sass": "^4.5.3", + "react": "^15.6.1", + "react-dom": "^15.6.1", + "react-redux": "^5.0.6", + "react-router": "^4.2.0", + "react-router-dom": "^4.2.2", + "redux": "^3.7.2", + "redux-mock-store": "^1.2.3", + "superagent": "^3.6.0", + "uglifyjs-webpack-plugin": "^0.4.6", + "uuid": "^3.1.0", + "webpack": "^3.5.6", + "webpack-dev-server": "^2.7.1" + } +} diff --git a/lab-nathan/frontend/src/__test__/token-reducer.test.js b/lab-nathan/frontend/src/__test__/token-reducer.test.js new file mode 100644 index 0000000..0e0ca69 --- /dev/null +++ b/lab-nathan/frontend/src/__test__/token-reducer.test.js @@ -0,0 +1,24 @@ +import tokenReducer from '../reducers/token-reducer.js'; + +describe('Token Reducer', function() { + it('TOKEN_SET should return a token.', function() { + let testAction = { + type: 'TOKEN_SET', + payload: 'kasdf;kasdfj;asdfj;s' + }; + + let result = tokenReducer(null, testAction); + + expect(result).toEqual(testAction.payload); + }); + + it('TOKEN_DELETE should delete a token.', function() { + let testAction = { + type: 'TOKEN_DELETE' + }; + + let result = tokenReducer(null, testAction); + + expect(result).toEqual(null); + }) +}); \ No newline at end of file diff --git a/lab-nathan/frontend/src/actions/profile-actions.js b/lab-nathan/frontend/src/actions/profile-actions.js new file mode 100644 index 0000000..62a804f --- /dev/null +++ b/lab-nathan/frontend/src/actions/profile-actions.js @@ -0,0 +1,23 @@ +import superagent from 'superagent'; + +export const profileCreate = (profile) => ({ + type: 'PROFILE_CREATE', + payload: profile +}); + +export const profileUpdate = (profile) => ({ + type: 'PROFILE_UPDATE', + payload: profile +}); + +export const profileCreateRequest = (profile) => (dispatch, getState) => { + let {token} = getState(); + return superagent.post(`${__API_URL__}/profiles`) + .set('Authorization', `Bearer ${token}`) + .field('bio', profile.bio) + .attach('avatar', profile.avatar) + .then(response => { + dispatch(profileCreate(response.body)); + return response; + }) +} \ No newline at end of file diff --git a/lab-nathan/frontend/src/actions/token-actions.js b/lab-nathan/frontend/src/actions/token-actions.js new file mode 100644 index 0000000..5fc8298 --- /dev/null +++ b/lab-nathan/frontend/src/actions/token-actions.js @@ -0,0 +1,37 @@ +import superagent from 'superagent'; + +export const tokenSet = (token) => ({ + type: 'TOKEN_SET', + payload: token +}); + +export const tokenDelete = (token) => ({ + type: 'TOKEN_DELETE' +}); + +export const signupRequest = (user) => (dispatch) => { + return superagent.post(`${__API_URL__}/signup`) + .withCredentials() + .send(user) + .then(response => { + dispatch(tokenSet(response.text)); + try { + localStorage.token = response.text; + } + catch (error) { + console.log(error); + } + + return response; + }); +}; + +export const loginRequest = (user) => (dispatch) => { + return superagent.get(`${__API_URL__}/login`) + .withCredentials() + .auth(user.username, user.password) + .then(response => { + dispatch(tokenSet(response.text)) + return response; + }); +} \ No newline at end of file diff --git a/lab-nathan/frontend/src/components/app/app.js b/lab-nathan/frontend/src/components/app/app.js new file mode 100644 index 0000000..a655b09 --- /dev/null +++ b/lab-nathan/frontend/src/components/app/app.js @@ -0,0 +1,47 @@ +import React from 'react'; +import {BrowserRouter, Route, Link} from 'react-router-dom'; +import Landing from '../landing/landing.js'; +import Dashboard from '../dashboard/dashboard.js'; +import Settings from '../settings/settings.js'; +import * as util from '../../lib/utilities.js'; +import {tokenSet} from '../../actions/token-actions.js'; +import {connect} from 'react-redux'; + + +class App extends React.Component { + componentDidMount() { + let token = util.readCookie('X-Sluggram-Token'); + if (token) { + this.props.tokenSet(token); + } + } + + render() { + return ( +
+ +
+
    +
  • Sign Up
  • +
  • Log In
  • +
  • Settings
  • +
+ + + +
+
+
+ ); + } +} + +let mapStateToProps = (state) => ({ + profile: state.profile +}); + +let mapDispatchToProps = (dispatch) => ({ + tokenSet: (token) => dispatch(tokenSet(token)) +}); + +export default connect(mapStateToProps, mapDispatchToProps)(App); \ No newline at end of file diff --git a/lab-nathan/frontend/src/components/dashboard/dashboard.js b/lab-nathan/frontend/src/components/dashboard/dashboard.js new file mode 100644 index 0000000..e11f438 --- /dev/null +++ b/lab-nathan/frontend/src/components/dashboard/dashboard.js @@ -0,0 +1,11 @@ +import React from 'react'; + +class Dashboard extends React.Component { + render() { + return ( +

Welcome

+ ); + } +} + +export default Dashboard; \ No newline at end of file diff --git a/lab-nathan/frontend/src/components/landing/landing.js b/lab-nathan/frontend/src/components/landing/landing.js new file mode 100644 index 0000000..092cec0 --- /dev/null +++ b/lab-nathan/frontend/src/components/landing/landing.js @@ -0,0 +1,28 @@ +import React from 'react'; +import {connect} from 'react-redux'; +import {signupRequest, loginRequest} from '../../actions/token-actions.js'; +import Login from '../login/login.js'; + +class Landing extends React.Component { + render() { + let {params} = this.props.match; + let handleComplete = params.auth === 'login' + ? this.props.login + : this.props.signup; + + return ( +
+ +
+ ); + } +} + +let mapDispatchToProps = (dispatch) =>({ + signup: (user) => dispatch(signupRequest(user)), + login: (user) => dispatch(loginRequest(user)) +}) + +export default connect(null, mapDispatchToProps)(Landing); \ No newline at end of file diff --git a/lab-nathan/frontend/src/components/login/login.js b/lab-nathan/frontend/src/components/login/login.js new file mode 100644 index 0000000..c34f288 --- /dev/null +++ b/lab-nathan/frontend/src/components/login/login.js @@ -0,0 +1,89 @@ +import React from 'react'; +import * as utilities from '../../lib/utilities.js'; + +class Login extends React.Component { + constructor(props) { + super(props); + this.state = { + username: '', + email: '', + password: '', + usernameError: null, + passwordError: null, + emailError: null, + error: null + } + + this.handleChange = this.handleChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + handleChange(e) { + let {name, value} = e.target; + this.setState({ + [name]: value, + usernameError: name === 'username' && !value ? 'username required' : null, + emailError: name === 'email' && !value ? 'email required' : null, + passwordError: name === 'password' && !value ? 'password required' : null + }); + } + + handleSubmit(e) { + e.preventDefault(); + this.props.onComplete(this.state) + .then(() => this.setState({ username: '', email: '', password: '' })) + .catch(error => { + console.error(error); + this.setState({error}); + }); + } + + render() { + return ( +
+ + {utilities.renderIf(this.props.auth === 'signup', + + )} + + {utilities.renderIf(this.state.usernameError, + + {this.state.usernameError} + + )} + + + + {utilities.renderIf(this.state.passwordError, + + {this.state.passwordError} + + )} + + + + + +
+ ); + } +} + +export default Login; diff --git a/lab-nathan/frontend/src/components/profile-form/profile-form.js b/lab-nathan/frontend/src/components/profile-form/profile-form.js new file mode 100644 index 0000000..4b4ac6d --- /dev/null +++ b/lab-nathan/frontend/src/components/profile-form/profile-form.js @@ -0,0 +1,69 @@ +import React from 'react'; +import * as util from '../../lib/utilities.js'; + +class ProfileForm extends React.Component { + constructor(props) { + super(props); + this.state = props.profile + ? { ...props.profile, preview: '' } + : { bio: '', avatar: null, preview: '' } + + this.handleChange = this.handleChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + componentWillReceiveProps(props) { + if (props.profile) { + this.setState(props.profile); + } + } + + handleChange(e) { + let {type, name} = e.target; + if (name === 'bio') { + this.setState({ bio: e.target.value }); + } + + if (name === 'avatar') { + let {files} = e.target; + let avatar = files[0]; + + this.setState({ avatar }); + + util.photoToDataURL(avatar) + .then(preview => this.setState({preview})) + .catch(console.error); + } + } + + handleSubmit(e) { + e.preventDefault(); + this.props.onComplete(this.state); + } + + render() { + return ( +
+ + + + + + + + +
+ ) + } +} + +export default ProfileForm; \ No newline at end of file diff --git a/lab-nathan/frontend/src/components/settings/settings.js b/lab-nathan/frontend/src/components/settings/settings.js new file mode 100644 index 0000000..431f0d0 --- /dev/null +++ b/lab-nathan/frontend/src/components/settings/settings.js @@ -0,0 +1,49 @@ +import React from 'react'; +import {connect} from 'react-redux'; +import ProfileForm from '../profile-form/profile-form.js'; +import {profileCreateRequest} from '../../actions/profile-actions.js'; + +class SettingsContainer extends React.Component { + constructor(props) { + super(props); + this.handleProfileCreate = this.handleProfileCreate.bind(this); + this.handleProfileUpdate = this.handleProfileUpdate.bind(this); + } + + handleProfileCreate(profile) { + return this.props.profileCreate(profile) + .then(response => { + console.log(response); + }) + .catch(console.error); + } + + handleProfileUpdate() { + // TODO + } + + render() { + let handleComplete = this.props.profile + ? this.handleProfileCreate + : this.handleProfileUpdate; + + return ( +
+

Profile Settings

+ +
+ ); + } +} + +let mapStateToProps = (state) => ({ + profile: state.profile +}) + +let mapDispatchToProps = (dispatch) => ({ + profileCreate: (profile) => dispatch(profileCreateRequest(profile)) +}) + +export default connect(mapStateToProps, mapDispatchToProps)(SettingsContainer); \ No newline at end of file diff --git a/lab-nathan/frontend/src/index.html b/lab-nathan/frontend/src/index.html new file mode 100644 index 0000000..44d42a5 --- /dev/null +++ b/lab-nathan/frontend/src/index.html @@ -0,0 +1,9 @@ + + + + CF Gram + + +
+ + \ No newline at end of file diff --git a/lab-nathan/frontend/src/lib/create-store.js b/lab-nathan/frontend/src/lib/create-store.js new file mode 100644 index 0000000..be5ac95 --- /dev/null +++ b/lab-nathan/frontend/src/lib/create-store.js @@ -0,0 +1,6 @@ +import {createStore, applyMiddleware} from 'redux'; +import reducers from '../reducers/reducers.js'; +import thunk from './thunk.js'; +import reporter from './reporter.js'; + +export default () => createStore(reducers, applyMiddleware(thunk, reporter)); \ No newline at end of file diff --git a/lab-nathan/frontend/src/lib/reporter.js b/lab-nathan/frontend/src/lib/reporter.js new file mode 100644 index 0000000..7350185 --- /dev/null +++ b/lab-nathan/frontend/src/lib/reporter.js @@ -0,0 +1,14 @@ +export default store => next => action => { + console.log('__ACTION__', action); + + try { + let result = next(action); + console.log('__STATE__', store.getState()); + return result; + } + catch(error) { + error.action = action; + console.error('__ERROR__', error); + return error; + } +} \ No newline at end of file diff --git a/lab-nathan/frontend/src/lib/thunk.js b/lab-nathan/frontend/src/lib/thunk.js new file mode 100644 index 0000000..c3a44ca --- /dev/null +++ b/lab-nathan/frontend/src/lib/thunk.js @@ -0,0 +1,6 @@ +export default store => next => action => { + if (typeof action === 'function') + return action(store.dispatch, store.getState); + else + return next(action); +} \ No newline at end of file diff --git a/lab-nathan/frontend/src/lib/utilities.js b/lab-nathan/frontend/src/lib/utilities.js new file mode 100644 index 0000000..0349f70 --- /dev/null +++ b/lab-nathan/frontend/src/lib/utilities.js @@ -0,0 +1,59 @@ +export const log = (...args) => +__DEBUG__ ? console.log(...args) : undefined; + +export const logError = (...args) => +__DEBUG__ ? console.error(...args) : undefined; + +export const renderIf = (test, component) => test ? component : undefined; + +export const classToggler = (options) => +Object.keys(options).filter(key => Boolean(options)).join(' '); + +export const map = (list, ...args) => +Array.prototype.map.apply(list, args); + +export const filter = (list, ...args) => +Array.prototype.filter.apply(list, args); + +export const reduce = (list, ...args) => +Array.prototype.reduce.apply(list, args); + +export const photoToDataURL = (file) => { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + + reader.addEventListener('load', () => { + resolve(reader.result); + }); + + reader.addEventListener('error', () => { + reject(reader.error); + }); + + if (file) { + return reader.readAsDataURL(file); + } + + return reject(new Error('USAGE ERROR: requires file')); + }); +} + +// https://stackoverflow.com/questions/14573223/set-cookie-and-get-cookie-with-javascript +export const readCookie = (name) => { + var nameEquals = name + '='; + var attributes = document.cookie.split(';'); + + for (var i = 0; i < attributes.length; i++) { + var attribute = attributes[i]; + + while (attribute.charAt(0) == ' ') { + attribute = attribute.substring(1, attribute.length); + } + + if (attribute.indexOf(nameEquals) == 0) { + return attribute.substring(nameEquals.length, attribute.length); + } + } + + return null; +} \ No newline at end of file diff --git a/lab-nathan/frontend/src/main.js b/lab-nathan/frontend/src/main.js new file mode 100644 index 0000000..0b18b68 --- /dev/null +++ b/lab-nathan/frontend/src/main.js @@ -0,0 +1,15 @@ +import React from 'react'; +import ReactDom from 'react-dom'; +import {Provider} from 'react-redux'; +import App from './components/app/app.js'; +import createStore from './lib/create-store.js'; + +let AppContainer = () => { + return ( + + + + ) +} + +ReactDom.render(, document.getElementById('root')); \ No newline at end of file diff --git a/lab-nathan/frontend/src/reducers/profile-reducer.js b/lab-nathan/frontend/src/reducers/profile-reducer.js new file mode 100644 index 0000000..e0ffa5f --- /dev/null +++ b/lab-nathan/frontend/src/reducers/profile-reducer.js @@ -0,0 +1,22 @@ +let validateProfileCreate = (profile) => { + if (!profile.avatar || profile.bio || !profile._id || !profile.owner || !profile.username || !profile.email) { + throw new Error('VALIDATION_ERROR: profile requires additional info'); + } +} + +export default (state = null, action) => { + let {type, payload} = action; + + switch(type) { + case 'PROFILE_CREATE': { + validateProfileCreate(payload); + return payload; + } + case 'PROFILE_UPDATE': + return {...state, ...payload}; + case 'LOGOUT': + return null; + default: + return state; + } +} diff --git a/lab-nathan/frontend/src/reducers/reducers.js b/lab-nathan/frontend/src/reducers/reducers.js new file mode 100644 index 0000000..f4da263 --- /dev/null +++ b/lab-nathan/frontend/src/reducers/reducers.js @@ -0,0 +1,8 @@ +import {combineReducers} from 'redux'; +import tokenReducer from './token-reducer.js'; +import profileReducer from './profile-reducer.js'; + +export default combineReducers({ + token: tokenReducer, + profile: profileReducer +}); \ No newline at end of file diff --git a/lab-nathan/frontend/src/reducers/token-reducer.js b/lab-nathan/frontend/src/reducers/token-reducer.js new file mode 100644 index 0000000..796a6a2 --- /dev/null +++ b/lab-nathan/frontend/src/reducers/token-reducer.js @@ -0,0 +1,11 @@ +export default (state = null, action) => { + let {type, payload} = action; + switch(type) { + case 'TOKEN_SET': + return payload + case 'TOKEN_DELETE': + return null + default: + return state + } +} \ No newline at end of file diff --git a/lab-nathan/frontend/webpack.config.js b/lab-nathan/frontend/webpack.config.js new file mode 100644 index 0000000..bee537e --- /dev/null +++ b/lab-nathan/frontend/webpack.config.js @@ -0,0 +1,91 @@ +'use strict'; + +require('dotenv').config(); + +const production = process.env.NODE_ENV === 'production'; + +const { DefinePlugin, EnvironmentPlugin } = require('webpack'); +const HtmlPlugin = require('html-webpack-plugin'); +const CleanPlugin = require('clean-webpack-plugin'); +const UglifyPlugin = require('uglifyjs-webpack-plugin'); +const ExtractPlugin = require('extract-text-webpack-plugin'); + +console.log(process.env.API_URL); + +let plugins = [ + new EnvironmentPlugin(['NODE_ENV']), + new ExtractPlugin('bundle-[hash].css'), + new HtmlPlugin({ template: `${__dirname}/src/index.html` }), + new DefinePlugin({ + '__DEBUG__': JSON.stringify('development'), + '__API_URL__': JSON.stringify(process.env.API_URL) + }) +]; + +if (production) { + plugins = plugins.concat([ new CleanPlugin(), new UglifyPlugin() ]); +} + +module.exports = { + plugins, + entry: `${__dirname}/src/main.js`, + devServer: { + historyApiFallback: true + }, + devtool: production ? undefined : 'eval', + output: { + path: `${__dirname}/build`, + filename: 'bundle-[hash].js', + publicPath: process.env.CDN_URL + }, + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + loader: 'babel-loader' + }, + { + test: /\.scss$/, + loader: ExtractPlugin.extract(['css-loader', 'sass-loader']) + }, + { + test: /\.(woff|woff2|ttf|eot|glyph|\.svg)$/, + use: [ + { + loader: 'url-loader', + options: { + limit: 10000, + name: 'font/[name].[ext]' + } + } + ] + }, + { + test: /\.(jpg|jpeg|gif|png|tiff|svg)$/, + exclude: /\.glyph.svg/, + use: [ + { + loader: 'url-loader', + options: { + limit: 6000, + name: 'image/[name].[ext]' + }, + } + ] + }, + { + test: /\.(mp3|aac|aiff|wav|flac|m4a|mp4|ogg|ape)$/, + exclude: /\.glyph.svg/, + use: [ + { + loader: 'file-loader', + options: { + name: 'audio/[name].[ext]' + } + } + ] + } + ] + } +};