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 (
+
+ );
+ }
+}
+
+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 (
+
+ );
+ }
+}
+
+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]'
+ }
+ }
+ ]
+ }
+ ]
+ }
+};