diff --git a/lib/actions/root.js b/lib/actions/root.js new file mode 100644 index 0000000..5d6ca07 --- /dev/null +++ b/lib/actions/root.js @@ -0,0 +1,13 @@ +import { bindActionCreators } from 'redux'; + +import * as ActionsSession from 'vanth/actions/session'; +import * as ActionsURL from 'vanth/actions/url'; +import * as ActionsUser from 'vanth/actions/user'; + +import RootStore from 'vanth/store/root'; + +module.exports = { + Session : bindActionCreators(ActionsSession, RootStore.dispatch), + URL : bindActionCreators(ActionsURL, RootStore.dispatch), + User : bindActionCreators(ActionsUser, RootStore.dispatch), +} diff --git a/lib/actions/session.js b/lib/actions/session.js new file mode 100644 index 0000000..4818888 --- /dev/null +++ b/lib/actions/session.js @@ -0,0 +1,35 @@ +import * as ActionTools from 'vanth/actions/tools'; + +export function createSession(username, password, nextPath=null) { + const payload = { + password: password, + username: username, + }; + return ActionTools.fetchAndDispatch( + '/session/', + 'SESSION_POST_BEGIN', + 'SESSION_POST_COMPLETE', + 'SESSION_POST_ERROR', + ActionTools.Methods.POST, + payload, + ); +} + +export function get() { + return ActionTools.fetchAndDispatch( + '/session/', + 'SESSION_GET_BEGIN', + 'SESSION_GET_COMPLETE', + 'SESSION_GET_ERROR' + ); +} + +export function logout(uri) { + return ActionTools.fetchAndDispatch( + uri, + 'SESSION_DELETE_BEGIN', + 'SESSION_DELETE_COMPLETE', + 'SESSION_DELETE_ERROR', + ActionTools.Methods.DELETE, + ); +} diff --git a/lib/actions/tools.js b/lib/actions/tools.js new file mode 100644 index 0000000..c3516d0 --- /dev/null +++ b/lib/actions/tools.js @@ -0,0 +1,102 @@ +import _ from 'lodash'; + +import Config from 'vanth/config'; +import { ActionType } from 'vanth/constants'; +import * as Fetch from 'vanth/fetch'; + +export const Methods = { + DELETE : 'delete', + GET : 'get', + PATCH : 'patch', + POST : 'post', + PUT : 'put', +} + +export function action(type) { + return function(data) { + let action = { + type: ActionType[type], + } + if(data != undefined) { + action.data = data; + } + if(!action.type) { + throw new Error(`An action type is required. Could not find an action type constant for ${type}`); + } + return action; + } +} + +export function ensureConstantAction(constant) { + if(typeof constant === "string") { + if(!ActionType[constant]) { + let message = `${constant} is not a valid constant - you'll need to add it to constants.js`; + console.error(message) + throw new Error(message); + } + return action(constant); + } + return constant; +} + +export function fetchAndDispatch(url, start, end, failed, method=Methods.GET, payload) { + let actionStart = ensureConstantAction(start); + let actionEnd = ensureConstantAction(end); + let actionFailed = ensureConstantAction(failed); + + if(!Fetch[method]) { + throw new Error(`Invalid method for fetcher: ${method}`); + } + + if(!payload && (method === Methods.POST || method === Methods.PUT)) { + throw new Error(`A payload is required for a ${method} method`); + } + + let actionData = {}; + if(method === Methods.PUT || method === Methods.DELETE) { + actionData.uri = url; + } else { + actionData.url = url; + } + + return dispatch => { + dispatch(actionStart(actionData)); + let fullURL = url.indexOf('://') >= 0 ? url : Config.API + url; + return Fetch[method].call(this, fullURL, payload) + .then(response => { + let result = response.json; + switch(method) { + case Methods.GET: + break; + case Methods.POST: + result = _.assign({}, payload, { + uri: response.headers.get('Location') + }, actionData); + break; + case Methods.PUT: + result = _.assign({}, payload, { + uri: url + }); + break; + case Methods.DELETE: + result = actionData; + break; + } + let eventType = { + [Methods.DELETE] : 'RESOURCE_DELETE', + [Methods.GET] : 'RESOURCE_GET', + [Methods.PUT] : 'RESOURCE_PUT', + [Methods.POST] : 'RESOURCE_POST', + }[method]; + dispatch(ensureConstantAction(eventType)(result)); + dispatch(actionEnd(result)); + return result; + }) + .catch(data => { + actionData.errors = data.errors ? data.errors : [data]; + dispatch(actionFailed(actionData)); + throw data; + }); + } +} + diff --git a/lib/actions/url.js b/lib/actions/url.js new file mode 100644 index 0000000..950c914 --- /dev/null +++ b/lib/actions/url.js @@ -0,0 +1,29 @@ +import { ActionType } from 'vanth/constants'; + +export function change(oldURL, newURL) { + return { + type : ActionType.URL_CHANGE, + data : { + oldURL : oldURL, + newURL : newURL, + } + } +} + +export function replace(newURL) { + let oldURL = window.location.href; + history.replaceState(null, '', '#' + newURL); + return change(oldURL, window.location.href); +} + +export function navigate(newURL, query) { + let oldURL = window.location.href; + let url = '#' + newURL; + if(query) { + url = "?nextPath=" + query + url; + } else { + url = "/" + url; + } + history.pushState(null, '', url); + return change(oldURL, window.location.href); +} diff --git a/lib/actions/user.js b/lib/actions/user.js new file mode 100644 index 0000000..5814fcc --- /dev/null +++ b/lib/actions/user.js @@ -0,0 +1,17 @@ +import * as ActionTools from 'vanth/actions/tools'; + +export function register(name, username, password) { + const payload = { + name : name, + password: password, + username: username, + } + return ActionTools.fetchAndDispatch( + '/user/', + 'USER_REGISTER_BEGIN', + 'USER_REGISTER_COMPLETE', + 'USER_REGISTER_ERROR', + ActionTools.Methods.POST, + payload + ); +} diff --git a/lib/app.js b/lib/app.js new file mode 100644 index 0000000..b3cfb33 --- /dev/null +++ b/lib/app.js @@ -0,0 +1,38 @@ +import { connect, Provider } from 'react-redux'; +import React from 'react'; +import ReactDOM from 'react-dom'; + +import Actions from 'vanth/actions/root'; +import RootStore from 'vanth/store/root'; +import Routes from 'vanth/routes'; + +const App = connect(state => state)(React.createClass({ + componentWillMount: function() { + window.onhashchange = function(event) { + if(!event) return; + Actions.URL.change(event.oldURL, event.newURL || window.location.hash); + } + }, + render: function() { + let allProps = _.assign({}, this.props, this.state); + return ( + + ); + } +})); + +ReactDOM.render(( + + + +), document.getElementById('container')); + +window.onload = function() { + let thing = Actions.Session.get() + thing.then(session => { + console.log(session); + }).catch(error => { + //Actions.URL.navigate('/login'); + }); +} +module.exports = App; diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..aea2928 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,3 @@ +module.exports = { + API: 'http://www.vanth.com', +} diff --git a/lib/constants.js b/lib/constants.js new file mode 100644 index 0000000..1195e21 --- /dev/null +++ b/lib/constants.js @@ -0,0 +1,33 @@ +var make_constants = function(constants) { + let result = {}; + for(var i = 0; i < constants.length; i++) { + let constant = constants[i]; + result[constant] = constant; + } + return result; +} + +export const ActionType = make_constants([ + 'RESOURCE_DELETE', + 'RESOURCE_GET', + 'RESOURCE_POST', + 'RESOURCE_PUT', + + 'SESSION_DELETE_BEGIN', + 'SESSION_DELETE_COMPLETE', + 'SESSION_DELETE_ERROR', + 'SESSION_GET_BEGIN', + 'SESSION_GET_COMPLETE', + 'SESSION_GET_ERROR', + 'SESSION_POST_BEGIN', + 'SESSION_POST_COMPLETE', + 'SESSION_POST_ERROR', + + 'URL_CHANGE', + 'URL_NAVIGATE', + 'URL_REPLACE', + + 'USER_REGISTER_BEGIN', + 'USER_REGISTER_COMPLETE', + 'USER_REGISTER_ERROR', +]); diff --git a/lib/dashboard.js b/lib/dashboard.js new file mode 100644 index 0000000..642cb95 --- /dev/null +++ b/lib/dashboard.js @@ -0,0 +1,33 @@ +import * as BS from 'react-bootstrap'; +import React from 'react'; + +import * as Actions from 'vanth/actions/root'; + +var Dashboard = React.createClass({ + logout: function() { + Actions.Session.logout(this.props.session.uri).then(() => { + Actions.URL.navigate('/login'); + }); + }, + render: function() { + return ( +
+ + + + Vanth + + + + Thing + + Logout + + + +
+ ); + } +}); + +module.exports = Dashboard diff --git a/lib/fetch.js b/lib/fetch.js new file mode 100644 index 0000000..b449335 --- /dev/null +++ b/lib/fetch.js @@ -0,0 +1,107 @@ +function FetchError(url, status, errors) { + this.errors = errors; + this.message = `Status code ${status} was returned from ${url}`; + this.name = 'FetchError'; + this.status = status; + this.url = url; +} + +let _handleResults = function(resolve, reject, url, fetchRequest) { + fetchRequest + .then(response => { + if(response.status == 204) { + resolve({ + headers : response.headers, + json : null, + text : '', + }); + } else if(response.status >= 400) { + console.error(`The request to ${url} failed with status ${response.status}`, response); + response.json() + .then(json => { + reject(new FetchError(url, response.status, json.errors)); + }) + .catch(reject); + } else { + if(response.headers.get('Content-Type') == 'application/json') { + response.json() + .then(json => { + resolve({ + headers : response.headers, + json : json, + text : null, + }); + }).catch(reject); + } else { + response.text() + .then(text => { + resolve({ + headers : response.headers, + json : null, + text : text, + }); + }).catch(reject); + } + } + }) + .catch(error => { + if(error instanceof Error) { + reject(error); + } else { + console.error("Unrecognized error raised when fetching", error); + reject(new Error("Unknown error occurred during fetch")); + } + }); +} + +export function get(url) { + return new Promise(function(resolve, reject) { + _handleResults(resolve, reject, url, + fetch(url, { + credentials : 'include', + method : 'GET', + }) + ); + }); +} + +export function post(url, data) { + return new Promise(function(resolve, reject) { + _handleResults(resolve, reject, url, + fetch(url, { + credentials : 'include', + headers : { + "Content-Type": "application/json" + }, + method : 'POST', + body : JSON.stringify(data), + }) + ); + }); +} + +export function put(url, data) { + return new Promise(function(resolve, reject) { + _handleResults(resolve, reject, url, + fetch(url, { + credentials : 'include', + headers : { + "Content-Type": "application/json" + }, + method : 'PUT', + body : JSON.stringify(data), + }) + ); + }); +} + +module.exports.delete = function(url) { + return new Promise(function(resolve, reject) { + _handleResults(resolve, reject, url, + fetch(url, { + credentials : 'include', + method : 'DELETE', + }) + ); + }); +} diff --git a/lib/login.js b/lib/login.js new file mode 100644 index 0000000..2659738 --- /dev/null +++ b/lib/login.js @@ -0,0 +1,76 @@ +import React from 'react'; +import * as BS from 'react-bootstrap'; +import { bindActionCreators } from 'redux'; + +import Actions from 'vanth/actions/root'; + +module.exports = React.createClass({ + getInitialState() { + return { + password : null, + username : null, + } + }, + + handleChange: function(parameter) { + return (e) => { + this.setState({ + [parameter] : e.target.value + }); + } + }, + + handleSubmit: function(e) { + e.preventDefault(); + + Actions.Session.createSession( + this.state.username, + this.state.password, + this.props.url.search.nextPath || "/" + ).then(result => { + Actions.Session.get(); + Actions.URL.navigate('/'); + }).catch(error => { + console.error(error); + }); + }, + + render: function() { + const forgotPassword = Forgot Password; + const pending = false; + return ( + + + +

Login

+
+
+ + Username + + Password + + Login + Register + +
+
+
+
+ ); + } +}); diff --git a/lib/middleware.js b/lib/middleware.js new file mode 100644 index 0000000..d941a02 --- /dev/null +++ b/lib/middleware.js @@ -0,0 +1,12 @@ +import { applyMiddleware, createStore } from 'redux'; + +import thunkMiddleware from 'redux-thunk'; +import createLogger from 'redux-logger'; + +const loggerMiddleware = createLogger(); +var createStoreWithMiddleware = applyMiddleware( + thunkMiddleware, // lets us dispatch() functions + loggerMiddleware // neat middleware that logs actions +)(createStore); + +module.exports = createStoreWithMiddleware; diff --git a/lib/register.js b/lib/register.js new file mode 100644 index 0000000..39099f3 --- /dev/null +++ b/lib/register.js @@ -0,0 +1,87 @@ +import React from 'react'; +import * as BS from 'react-bootstrap'; +import { bindActionCreators } from 'redux'; + +import Actions from 'vanth/actions/root'; + + +module.exports = React.createClass({ + getInitialState() { + return { + name : null, + password : null, + username : null, + } + }, + + handleChange: function(parameter) { + return (e) => { + this.setState({ + [parameter] : e.target.value + }); + } + }, + + handleSubmit: function(e) { + e.preventDefault(); + + Actions.User.register( + this.state.name, + this.state.username, + this.state.password, + this.props.url.search.nextPath || "/" + ).then(result => { + Actions.Session.get(); + Actions.URL.navigate('/'); + }).catch(error => { + console.error(error); + }); + }, + + render: function() { + const pending = false; + return ( + + + +

Vanth - register a new user

+
+
+ + Name + + Username + + Password + + Register + Login + +
+
+
+
+ ); + } +}); diff --git a/lib/routes.js b/lib/routes.js new file mode 100644 index 0000000..51892a4 --- /dev/null +++ b/lib/routes.js @@ -0,0 +1,63 @@ +import React from 'react'; +import PathToRegexp from 'path-to-regexp'; + +import Dashboard from 'vanth/dashboard'; +import Login from 'vanth/login'; +import Register from 'vanth/register'; + +const Router = React.createClass({ + routes: { + "/" : Dashboard, + "/login" : Login, + "/register" : Register, + }, + render: function() { + var toRender = null; + for(var path in this.routes) { + var element = this.routes[path]; + var keys = []; + var pattern = PathToRegexp(path, keys); + var match = pattern.exec(this.props.hash); + if(match) { + if(!!toRender) { + console.warn("Matched more than one route. First route was", toRender.path, " this match is ", path); + } + var route = {}; + for(var i = 0; i < keys.length; i++) { + let key = keys[i]; + route[key.name] = match[i+1]; + } + var props = _.assign({}, this.props, {route: route}); + toRender = { + element : React.createElement(element, props), + path : path + } + } + } + if(!toRender) { + return ( +
+

You seem to have reached a link that doesn't go anywhere. Maybe you want to go back to the beginning?

+
+ ); + } else { + return ( +
+ {toRender.element} +
+ ); + } + } +}); + +var Routes = React.createClass({ + render: function() { + var hash = this.props.url.location.hash.substr(1); + + return ( + + ); + } +}); + +module.exports = Routes diff --git a/lib/store/root.js b/lib/store/root.js new file mode 100644 index 0000000..b6e8b7f --- /dev/null +++ b/lib/store/root.js @@ -0,0 +1,14 @@ +import { combineReducers } from 'redux'; +import createStoreWithMiddleware from 'vanth/middleware'; + +import SessionReducer from 'vanth/store/session'; +import URLReducer from 'vanth/store/url'; + +const root = combineReducers({ + session : SessionReducer, + url : URLReducer, +}); + +const store = createStoreWithMiddleware(root); + +module.exports = store; diff --git a/lib/store/session.js b/lib/store/session.js new file mode 100644 index 0000000..1654f12 --- /dev/null +++ b/lib/store/session.js @@ -0,0 +1,19 @@ +import _ from 'lodash'; +import * as Constants from 'vanth/constants'; + +const emptyState = { + name : null, + username : null, + uri : null, +}; + +var reducer = function(state = emptyState, action) { + switch (action.type) { + case Constants.ActionType.SESSION_GET_COMPLETE: + return _.assign({}, state, action.data); + default: + return state; + } +} + +module.exports = reducer; diff --git a/lib/store/url.js b/lib/store/url.js new file mode 100644 index 0000000..e9cc0d6 --- /dev/null +++ b/lib/store/url.js @@ -0,0 +1,34 @@ +import urllite from 'urllite'; + +import { ActionType } from 'vanth/constants'; + +let _parseSearch = function(location) { + let search = {}; + let query = location.search.substring(1); + let vars = query.split('&'); + for(var i = 0; i < vars.length; i++) { + var pair = vars[i].split('='); + search[pair[0]] = decodeURIComponent(pair[1]); + } + return search; +} + +const initialState = { + location : urllite(window.location), + search : _parseSearch(urllite(window.location)), +}; + +var reducer = function(state = initialState, action) { + switch (action.type) { + case ActionType.URL_CHANGE: + let location = urllite(action.data.newURL); + return _.assign({}, state, { + location: location, + search: _parseSearch(location) + }); + default: + return state; + } +} + +module.exports = reducer;