├── .babelrc ├── .eslintrc ├── .gitignore ├── LICENSE.txt ├── README.md ├── circle.yml ├── client ├── Realtime.js ├── UserId.js └── app.js ├── config ├── default.json └── production.json ├── dbSetup.babel.js ├── dbSetup.js ├── images ├── favicon.gif └── favicon.ico ├── karma.conf.js ├── package.json ├── server.babel.js ├── server.js ├── server.webpack.js ├── server ├── api │ ├── http.js │ └── service │ │ └── event.js ├── app.js └── views │ └── index.ejs ├── start-dev.js ├── style ├── main.styl ├── pure.css └── spinner.styl ├── test ├── actions.test.js └── reducers.test.js ├── tests.webpack.js ├── universal ├── actions │ └── PulseActions.js ├── components │ ├── AsyncBar.js │ ├── EventInput.js │ ├── EventItem.js │ ├── EventList.js │ ├── EventTicker.js │ └── Header.js ├── constants │ └── ActionTypes.js ├── containers │ ├── MyEvents.js │ ├── OtherEvents.js │ ├── PulseApp.js │ ├── devTools.js │ └── root │ │ ├── index.js │ │ ├── root.dev.js │ │ └── root.prod.js ├── reducers │ ├── index.js │ └── pulse.js ├── routes.js └── store │ ├── configureStore.client.dev.js │ ├── configureStore.client.prod.js │ ├── configureStore.server.js │ └── index.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "comments": false, 3 | "presets": [ 4 | "es2015", 5 | "stage-0", 6 | "react" 7 | ], 8 | "plugins": [ 9 | "rewire", 10 | "system-import-transformer" 11 | ], 12 | "env": { 13 | "start": { 14 | "presets": ["react-hmre"] 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // I want to use babel-eslint for parsing! 3 | "parser": "babel-eslint", 4 | "ecmaFeatures": { 5 | "jsx": true, 6 | "classes": true, 7 | "modules": true, 8 | }, 9 | "env": { 10 | // I write for browser 11 | "browser": true, 12 | // in CommonJS 13 | "node": true 14 | }, 15 | "globals": { 16 | "process": false, 17 | "require": false, 18 | "define": false, 19 | "console": false, 20 | }, 21 | // To give you an idea how to override rule options: 22 | "rules": { 23 | "quotes": [2, "single"], 24 | "semi": [2, "always"], 25 | "strict": [2, "never"], 26 | "eol-last": [0], 27 | "no-mixed-requires": [0], 28 | "no-underscore-dangle": [0], 29 | "no-bitwise": 2, 30 | "camelcase": 2, 31 | "eqeqeq": 2, 32 | "wrap-iife": [2, "inside"], 33 | "no-use-before-define": [2, "nofunc"], 34 | "no-caller": 2, 35 | "no-undef": 2, 36 | "new-cap": 2, 37 | "react/jsx-uses-react": 2, 38 | "react/jsx-uses-vars": 2, 39 | "react/react-in-jsx-scope": 2 40 | }, 41 | "plugins": [ 42 | "react" 43 | ] 44 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | config/local.json 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present Gordon Dent 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 3REE 2 | [![Circle CI](https://circleci.com/gh/GordyD/3ree.svg?style=svg)](https://circleci.com/gh/GordyD/3ree) 3 | 4 | An example universal JS application written with the 3REE stack, *Re*act + *Re*dux + *Re*thinkDB + *E*xpress. A stack for building apps, front and back end, with just Javascript. 5 | 6 | This project was initially conceived to experiment with using these technologies in conjunction with one-another. I have written a [blog](http://blog.workshape.io/the-3ree-stack-react-redux-rethinkdb-express-js/) that relates to this codebase. 7 | 8 | ![Screenshot](http://i.imgur.com/RiFteKV.png) 9 | 10 | This project is useful for: 11 | - seeing how to build a Universal Javascript application 12 | - understanding how to handle asyncronousity in Redux action creators 13 | - seeing how you can use Socket.io with Redux 14 | - building your own Redux powered application 15 | - seeing how you can use System.import() with React Router + Webpack2 to acheive code splitting for different routes of your application 16 | - forking so that you can build your own 3REE stack app! 17 | 18 | ### Main Features 19 | 20 | - Universal (Isomorphic) Javascript Application 21 | - Use of Webpack 2's Code Splitting and Tree Shaking features 22 | - Asyncronous Redux actions example 23 | - Use of RethinkDB Changefeeds for realtime updates reflected in the UI 24 | 25 | ### Demo 26 | 27 | There is a demo app hosted at [3ree-demo.workshape.io](http://3ree-demo.workshape.io). Check it out. If it is down, please email tanc@workshape.io 28 | 29 | ### Setup 30 | 31 | You will need to install [RethinkDB](http://www.rethinkdb.com). You can find instruction on how to do so [here](http://rethinkdb.com/docs/install/). Make sure you have the latest version installed. 32 | 33 | - Clone the repo `git clone git@github.com:GordyD/3ree.git` 34 | - Make sure you are using Node v6.0.0 (I recommend using [n](https://github.com/tj/n) for Node version management) 35 | - Run `npm install` 36 | - If your local environment is not reflected by `config/default.json`, then add a file at `config/local.json` to provide local customisation. 37 | - Run `npm run db-setup` to set up DB 38 | 39 | ### Running Dev Server 40 | 41 | On Linux/OSX: `npm start` 42 | 43 | On Windows: `npm run start:win` 44 | 45 | This will start the Webpack dev server - for serving the client, as well as the server-side API. 46 | 47 | Go to http://localhost:3001 in two separate tabs - see changes propagate in real time (Hot Module Replacement works too). 48 | 49 | ### Running Production Server 50 | 51 | You will need to roll out your own deployment script for a server, but before you can ship you will need to: 52 | 53 | - Build the client with `npm run build:prod` 54 | - Ensure all production npm modules are installed on the server. e.g. `npm install --prod` 55 | - Rsync your application to your server 56 | - Set up nginx or your web server of choice to map HTTP requests for your URL to `http://localhost:3000` 57 | - Run `npm run start:prod` to run on your server 58 | - Go to your URL 59 | 60 | NOTE: Production has not been tested on Windows. 61 | 62 | ### Tech Used 63 | 64 | | **Tech** | **Description** | 65 | | ---------|-----------------| 66 | | [React](https://facebook.github.io/react/) | View layer | 67 | | [React Router](https://github.com/reactjs/react-router) | Universal routing | 68 | | [Redux](http://redux.js.org/) | State management | 69 | | [RethinkDB](http://www.rethinkdb.com) | Persistance layer | 70 | | [Express](http://expressjs.com/) | Node.js server framework | 71 | | [Socket.io]() | Used for realtime communication between clients and server | 72 | | [Webpack](https://webpack.github.io/) | Module bundling + build for client | 73 | | [Superagent](https://github.com/visionmedia/superagent) | Universal http requests | 74 | | [Stylus](http://stylus-lang.com/) | Expressive, dynamic, robust CSS | 75 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.0.0 4 | test: 5 | override: 6 | - npm run lint 7 | - npm test -------------------------------------------------------------------------------- /client/Realtime.js: -------------------------------------------------------------------------------- 1 | import socketClient from 'socket.io-client'; 2 | 3 | export function setupRealtime(store, actions) { 4 | const io = socketClient(); 5 | 6 | io.on('event-change', (change) => { 7 | let state = store.getState(); 8 | if (!change.old_val) { 9 | store.dispatch(actions.addEventSuccess(change.new_val)); 10 | } else if (!change.new_val) { 11 | store.dispatch(actions.deleteEventSuccess(change.old_val)); 12 | } else { 13 | store.dispatch(actions.editEventSuccess(change.new_val)); 14 | } 15 | }); 16 | 17 | return io; 18 | } -------------------------------------------------------------------------------- /client/UserId.js: -------------------------------------------------------------------------------- 1 | // This is a tiny UUID generator to replace node-uuid! 2 | // See: https://gist.github.com/jed/982883 3 | 4 | function uuid() { 5 | function b(a){ 6 | /*eslint-disable */ 7 | return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b) 8 | /*eslint-enable */ 9 | } 10 | 11 | return b(); 12 | } 13 | 14 | 15 | export function hasLocalStorage() { 16 | return (!!window.localStorage); 17 | } 18 | 19 | export function getUserId() { 20 | return window.localStorage.getItem('userId'); 21 | } 22 | 23 | export function setUserId() { 24 | let id = uuid(); 25 | window.localStorage.setItem('userId', id); 26 | return id; 27 | } 28 | 29 | export function getOrSetUserId() { 30 | if (!hasLocalStorage()) { 31 | return 'baseUser'; 32 | } else { 33 | let userId = getUserId(); 34 | return (userId) ? userId : setUserId(); 35 | } 36 | } -------------------------------------------------------------------------------- /client/app.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import React from 'react'; 3 | import { Router, Route, browserHistory } from 'react-router'; 4 | import { syncHistoryWithStore } from 'react-router-redux'; 5 | 6 | import { getOrSetUserId } from './UserId'; 7 | import { setupRealtime } from './Realtime'; 8 | 9 | import routes from '../universal/routes'; 10 | import store from '../universal/store'; 11 | import * as actions from '../universal/actions/PulseActions'; 12 | 13 | import Root from '../universal/containers/root'; 14 | 15 | import '../style/pure.css'; 16 | import '../style/main.styl'; 17 | import '../style/spinner.styl'; 18 | 19 | const history = syncHistoryWithStore(browserHistory, store); 20 | 21 | ReactDOM.render( 22 | , 23 | document.getElementById('app') 24 | ); 25 | 26 | // Now that we have rendered... 27 | setupRealtime(store, actions); 28 | 29 | // lets mutate state and set UserID as key from local storage 30 | store.dispatch(actions.setUserId(getOrSetUserId())); 31 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "rethinkdb": { 3 | "host": "localhost", 4 | "port": 28015, 5 | "db": "pulse" 6 | }, 7 | "express": { 8 | "host": "localhost", 9 | "port": 3000 10 | }, 11 | "buildDirectory": "build" 12 | } 13 | -------------------------------------------------------------------------------- /config/production.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildDirectory": "dist", 3 | "express": { 4 | "port": 3210 5 | } 6 | } -------------------------------------------------------------------------------- /dbSetup.babel.js: -------------------------------------------------------------------------------- 1 | require('babel-core/register'); 2 | require('./dbSetup'); 3 | -------------------------------------------------------------------------------- /dbSetup.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | import r from 'rethinkdb'; 3 | import config from 'config'; 4 | 5 | const rethinkdb = config.get('rethinkdb'); 6 | let DATABASE = rethinkdb.db || 'pulse'; 7 | let TABLES = ['pulses']; 8 | 9 | r.connect(rethinkdb) 10 | .then(conn => { 11 | console.log(' [-] Database Setup'); 12 | return createDbIfNotExists(conn) 13 | .then(() => Promise.all(TABLES.map((table) => createTableIfNotExists(conn, table)))) 14 | .then(() => closeConnection(conn)); 15 | }); 16 | 17 | function createDbIfNotExists(conn){ 18 | return getDbList(conn) 19 | .then((list) => { 20 | if(list.indexOf(DATABASE) === -1) { 21 | return createDatabase(conn); 22 | } else { 23 | console.log(' [!] Database already exists:', DATABASE); 24 | return Promise.resolve(true); 25 | } 26 | }); 27 | } 28 | 29 | function createTableIfNotExists(conn, table) { 30 | return getTableList(conn) 31 | .then((list) => { 32 | if(list.indexOf(table) === -1) { 33 | return createTable(conn, table); 34 | } else { 35 | console.log(' [!] Table already exists:', table); 36 | return Promise.resolve(true); 37 | } 38 | }); 39 | } 40 | 41 | function getDbList(conn) { 42 | return r.dbList().run(conn); 43 | } 44 | 45 | function getTableList(conn) { 46 | return r.db(DATABASE).tableList().run(conn); 47 | } 48 | 49 | function createDatabase(conn) { 50 | console.log(' [-] Create Database:', DATABASE); 51 | return r.dbCreate(DATABASE).run(conn); 52 | } 53 | 54 | function createTable(conn, table) { 55 | console.log(' [-] Create Table:', table); 56 | return r.db(DATABASE).tableCreate(table).run(conn); 57 | } 58 | 59 | function closeConnection(conn) { 60 | console.log(' [x] Close connection!'); 61 | return conn.close(); 62 | } -------------------------------------------------------------------------------- /images/favicon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GordyD/3ree/d4c2f3c250032350a07732ca66a6943a565c8690/images/favicon.gif -------------------------------------------------------------------------------- /images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GordyD/3ree/d4c2f3c250032350a07732ca66a6943a565c8690/images/favicon.ico -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var webpackConfig = require('./webpack.config.js'); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | browsers: [ 'PhantomJS' ], 6 | captureTimeout: 60000, 7 | browserNoActivityTimeout: 60000, // We need to accept that Webpack may take a while to build! 8 | singleRun: true, 9 | colors: true, 10 | frameworks: [ 'mocha', 'sinon', 'chai' ], // Mocha is our testing framework of choice 11 | files: [ 12 | './tests.webpack.js', 13 | ], 14 | preprocessors: { 15 | 'tests.webpack.js': [ 'webpack' ] // Preprocess with webpack and our sourcemap loader 16 | }, 17 | reporters: [ 'mocha' ], 18 | webpack: { // Simplified Webpack configuration 19 | entry: webpackConfig.entry, 20 | module: { 21 | rules: webpackConfig.module.rules, 22 | noParse: [ 23 | /node_modules\/sinon/, 24 | ] 25 | }, 26 | node: { 27 | fs: 'empty' 28 | } 29 | }, 30 | webpackServer: { 31 | noInfo: true // We don't want webpack output 32 | } 33 | }); 34 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "3ree", 3 | "version": "1.0.0", 4 | "description": "An example universal JS application written with the 3REE stack, React + Redux + RethinkDB + Express.", 5 | "main": "server.babel.js", 6 | "scripts": { 7 | "build:prod": "NODE_ENV=production webpack --display-chunks", 8 | "db-setup": "node dbSetup.babel.js", 9 | "lint": "eslint client server universal test server.js dbSetup.js", 10 | "start": "NODE_ENV=development node start-dev.js", 11 | "start:win": "set NODE_ENV=development&&node start-dev.js", 12 | "start:prod": "NODE_ENV=production node server.babel.js", 13 | "test": "./node_modules/karma/bin/karma start" 14 | }, 15 | "keywords": [ 16 | "react", 17 | "3ree", 18 | "rethinkdb", 19 | "redux", 20 | "webpack" 21 | ], 22 | "license": "MIT", 23 | "engines": { 24 | "node": ">=6.0.0", 25 | "npm": ">=3.8.6" 26 | }, 27 | "dependencies": { 28 | "babel": "6.23.x", 29 | "babel-core": "6.23.x", 30 | "babel-eslint": "7.1.1", 31 | "babel-loader": "6.4.x", 32 | "babel-plugin-rewire": "1.0.0", 33 | "babel-plugin-system-import-transformer": "^3.1.0", 34 | "babel-polyfill": "6.23.x", 35 | "babel-preset-es2015": "6.24.x", 36 | "babel-preset-react": "6.23.x", 37 | "babel-preset-react-hmre": "1.1.x", 38 | "babel-preset-stage-0": "6.22.x", 39 | "babel-runtime": "6.23.x", 40 | "babel-template": "6.23.x", 41 | "bluebird": "3.1.x", 42 | "body-parser": "1.14.x", 43 | "classnames": "2.2.x", 44 | "compression": "^1.6.2", 45 | "config": "1.19.x", 46 | "date-fns": "^1.28.2", 47 | "ejs": "2.3.x", 48 | "express": "4.13.x", 49 | "history": "1.13.x", 50 | "nib": "^1.1.x", 51 | "react": "15.0.x", 52 | "react-dom": "15.0.x", 53 | "react-redux": "4.4.x", 54 | "react-router": "2.4.x", 55 | "react-router-redux": "4.0.x", 56 | "redux": "3.5.x", 57 | "redux-logger": "2.6.x", 58 | "redux-thunk": "2.1.x", 59 | "rethinkdb": "2.3.x", 60 | "serve-static": "1.10.x", 61 | "socket.io": "1.4.x", 62 | "socket.io-client": "1.4.x", 63 | "superagent": "1.8.x", 64 | "xss": "0.2.x" 65 | }, 66 | "devDependencies": { 67 | "chai": "3.5.x", 68 | "css-loader": "0.23.x", 69 | "eslint": "2.9.x", 70 | "eslint-plugin-react": "5.1.x", 71 | "extract-text-webpack-plugin": "2.1.x", 72 | "karma": "0.13.x", 73 | "karma-chai": "0.1.x", 74 | "karma-chrome-launcher": "1.0.x", 75 | "karma-mocha": "1.0.x", 76 | "karma-mocha-reporter": "2.0.x", 77 | "karma-phantomjs-launcher": "1.0.x", 78 | "karma-sinon": "1.0.x", 79 | "karma-webpack": "1.7.x", 80 | "mocha": "2.4.x", 81 | "mocha-loader": "0.7.x", 82 | "nock": "8.0.x", 83 | "node-libs-browser": "1.0.x", 84 | "phantomjs-prebuilt": "2.1.x", 85 | "raw-loader": "0.5.x", 86 | "react-hot-loader": "1.3.x", 87 | "react-transform-hmr": "1.0.x", 88 | "redux-devtools": "3.3.x", 89 | "redux-devtools-dock-monitor": "1.1.x", 90 | "redux-devtools-log-monitor": "1.0.x", 91 | "redux-mock-store": "1.0.x", 92 | "sinon": "1.17.x", 93 | "style-loader": "0.13.x", 94 | "stylus": "0.54.x", 95 | "stylus-loader": "2.5.x", 96 | "webpack": "2.2.x", 97 | "webpack-dev-server": "2.2.x" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /server.babel.js: -------------------------------------------------------------------------------- 1 | require('babel-core/register'); 2 | require('babel-polyfill'); 3 | require('./server.js'); -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import bodyParser from 'body-parser'; 3 | import express from 'express'; 4 | import compress from 'compression'; 5 | import http from 'http'; 6 | import socketIO from 'socket.io'; 7 | import config from 'config'; 8 | 9 | import * as api from './server/api/http'; 10 | import * as eventService from './server/api/service/event'; 11 | import * as uni from './server/app.js'; 12 | 13 | const app = express(); 14 | const httpServer = http.createServer(app); 15 | const port = config.get('express.port') || 3000; 16 | 17 | var io = socketIO(httpServer); 18 | 19 | app.set('views', path.join(__dirname, 'server', 'views')); 20 | app.set('view engine', 'ejs'); 21 | 22 | /** 23 | * Server middleware 24 | */ 25 | app.use(compress()); 26 | app.use(require('serve-static')(path.join(__dirname, config.get('buildDirectory')))); 27 | app.use(bodyParser.urlencoded({ 28 | extended: true 29 | })); 30 | app.use(bodyParser.json()); 31 | 32 | /** 33 | * API Endpoints 34 | */ 35 | app.get('/api/0/events', api.getEvents); 36 | app.post('/api/0/events', api.addEvent); 37 | app.post('/api/0/events/:id', api.editEvent); 38 | app.delete('/api/0/events/:id', api.deleteEvent); 39 | 40 | app.get('/favicon.ico', (req, res) => res.sendFile(path.join(__dirname, 'images', 'favicon.ico'))); 41 | 42 | /** 43 | * Universal Application endpoint 44 | */ 45 | app.get('*', uni.handleRender); 46 | 47 | eventService.liveUpdates(io); 48 | 49 | httpServer.listen(port); -------------------------------------------------------------------------------- /server.webpack.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development'; 2 | 3 | var webpack = require('webpack'); 4 | var WebpackDevServer = require('webpack-dev-server'); 5 | 6 | var config = require('config'); 7 | var webpackConfig = require('./webpack.config'); 8 | 9 | var host = 'localhost'; 10 | var appPort = 3000; 11 | var devServerPort = 3001; 12 | 13 | new WebpackDevServer(webpack(webpackConfig), { 14 | contentBase: [ config.get('buildDirectory'), '/' ].join(''), 15 | headers: { 'Access-Control-Allow-Origin': '*' }, 16 | historyApiFallback: true, 17 | hot: true, 18 | noInfo: false, 19 | publicPath: webpackConfig.output.publicPath, 20 | proxy: { 21 | '*': 'http://' + host + ':' + appPort 22 | } 23 | }).listen(devServerPort, host, function (err) { 24 | if (err) { 25 | console.log(err); 26 | } 27 | 28 | console.log('Webpack Dev Server running at ' + host + ':' + devServerPort); 29 | }); 30 | -------------------------------------------------------------------------------- /server/api/http.js: -------------------------------------------------------------------------------- 1 | import * as service from './service/event'; 2 | 3 | export function getEvents(req, res) { 4 | service.getEvents() 5 | .then((events) => res.json(events)) 6 | .catch(err => { 7 | res.status(400); 8 | res.json({error: err}); 9 | }); 10 | } 11 | 12 | export function addEvent(req, res) { 13 | service.addEvent(req.body) 14 | .then((event) => res.json(event)) 15 | .catch(err => { 16 | res.status(400); 17 | res.json({error: err, event: req.body}); 18 | }); 19 | } 20 | 21 | export function editEvent(req, res) { 22 | service.editEvent(req.params.id, req.body) 23 | .then((event) => res.json(event)) 24 | .catch(err => { 25 | res.status(400); 26 | res.json({error: err, event: req.body}); 27 | }); 28 | } 29 | 30 | export function deleteEvent(req, res) { 31 | service.deleteEvent(req.params.id) 32 | .then((event) => res.json(event)) 33 | .catch(err => { 34 | res.status(400); 35 | res.json({error: err, event: req.body}); 36 | }); 37 | } 38 | 39 | -------------------------------------------------------------------------------- /server/api/service/event.js: -------------------------------------------------------------------------------- 1 | import r from 'rethinkdb'; 2 | import config from 'config'; 3 | import xss from 'xss'; 4 | 5 | function connect() { 6 | return r.connect(config.get('rethinkdb')); 7 | } 8 | 9 | export function liveUpdates(io) { 10 | console.log('Setting up listener...'); 11 | connect() 12 | .then(conn => { 13 | r 14 | .table('pulses') 15 | .changes().run(conn, (err, cursor) => { 16 | console.log('Listening for changes...'); 17 | cursor.each((err, change) => { 18 | console.log('Change detected', change); 19 | io.emit('event-change', change); 20 | }); 21 | }); 22 | }); 23 | } 24 | 25 | export function getEvents() { 26 | return connect() 27 | .then(conn => { 28 | return r 29 | .table('pulses') 30 | .orderBy(r.desc('created')).run(conn) 31 | .then(cursor => cursor.toArray()); 32 | }); 33 | } 34 | 35 | export function addEvent(event) { 36 | return connect() 37 | .then(conn => { 38 | event.created = new Date(); 39 | event.text = xss(event.text); 40 | return r 41 | .table('pulses') 42 | .insert(event).run(conn) 43 | .then(response => { 44 | return Object.assign({}, event, {id: response.generated_keys[0]}); 45 | }); 46 | }); 47 | } 48 | 49 | export function editEvent(id, event) { 50 | event.updated = new Date(); 51 | event.text = xss(event.text); 52 | return connect() 53 | .then(conn => { 54 | return r 55 | .table('pulses') 56 | .get(id).update(event).run(conn) 57 | .then(() => event); 58 | }); 59 | } 60 | 61 | export function deleteEvent(id) { 62 | return connect() 63 | .then(conn => { 64 | return r 65 | .table('pulses') 66 | .get(id).delete().run(conn) 67 | .then(() => ({id: id, deleted: true})); 68 | }); 69 | } -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | import { Provider } from 'react-redux'; 4 | import { RouterContext, match } from 'react-router'; 5 | 6 | import * as eventService from './api/service/event'; 7 | import configureStore from '../universal/store'; 8 | import routes from '../universal/routes'; 9 | import DevTools from '../universal/containers/devTools'; 10 | 11 | const isDev = (process.env.NODE_ENV !== 'production'); 12 | 13 | export function handleRender(req, res) { 14 | console.log(' [x] Request for', req.url); 15 | eventService.getEvents() 16 | .then(initialEvents => { 17 | let initialState = {pulseApp: { events: initialEvents, userId: 'baseUser'} }; 18 | 19 | const store = configureStore(req, initialState); 20 | 21 | // Wire up routing based upon routes 22 | match({ routes, location: req.url }, (error, redirectLocation, renderProps) => { 23 | if (error || !renderProps) { 24 | 25 | if (req.url === '/bundle.js') { 26 | console.log(' | Hold up, are you sure you are hitting the app at http://localhost:3001?'); 27 | console.log(' | On development bundle.js is served by the Webpack Dev Server and so you need to hit the app on port 3001, not port 3000.'); 28 | } 29 | console.log((error) ? error : 'Error: No matching universal route found'); 30 | 31 | res.status(400); 32 | res.send((error) ? error : 'Error: No matching universal route found'); 33 | return; 34 | } 35 | 36 | if (redirectLocation) { 37 | res.redirect(redirectLocation); 38 | return; 39 | } 40 | 41 | const devTools = (isDev) ? : null; 42 | 43 | // Render the component to a string 44 | const html = ReactDOMServer.renderToString( 45 | 46 |
47 | 48 | {devTools} 49 |
50 |
51 | ); 52 | 53 | // Send the rendered page back to the client with the initial state 54 | res.render('index', { isProd: (!isDev), html: html, initialState: JSON.stringify(store.getState()) }); 55 | }); 56 | }); 57 | } -------------------------------------------------------------------------------- /server/views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Pulse Universal 5 | 6 | <% if (isProd) { %> 7 | 8 | <% } %> 9 | 10 | 11 | 12 | 13 | 14 |
<%- html %>
15 | 18 | 20 | 22 | 23 | -------------------------------------------------------------------------------- /start-dev.js: -------------------------------------------------------------------------------- 1 | require('./server.babel.js'); 2 | require('./server.webpack.js'); 3 | -------------------------------------------------------------------------------- /style/main.styl: -------------------------------------------------------------------------------- 1 | body 2 | background-color: #fff 3 | 4 | h1, h2, h3, p 5 | margin: 0px 6 | 7 | * 8 | font-family: 'Source Sans Pro', sans-serif 9 | 10 | .Pulse-header 11 | background-color: #252A3A 12 | color: #1B98F8 13 | margin-bottom: 20px 14 | box-shadow: 0px 3px 12px #4B7197 15 | 16 | h1 17 | padding: 20px 18 | text-transform: uppercase 19 | text-align: center 20 | 21 | .Pulse-links 22 | padding: 10px 23 | text-align: center 24 | 25 | a 26 | padding: 10px 27 | color: #fff 28 | text-decoration: none 29 | 30 | &:hover 31 | color: #cccccc 32 | 33 | &.active 34 | text-decoration: underline 35 | 36 | .Pulse-addEventForm, .Pulse-eventList, .Pulse-async 37 | width: 600px 38 | margin: 0 auto 39 | padding-bottom: 20px 40 | 41 | .Pulse-async 42 | text-align: center 43 | min-height: 30px 44 | 45 | .Pulse-async-error 46 | color: #EE5656 47 | font-weight: bold 48 | 49 | .Pulse-addEventForm 50 | form 51 | text-align: center 52 | 53 | form label 54 | color: #4B7197 55 | margin-left: 10px 56 | 57 | .Pulse-eventInput-value 58 | display: inline-block 59 | color: #4B7197 60 | margin-right: 15px 61 | font-weight: bold 62 | width: 40px 63 | 64 | .Pulse-eventList-summary 65 | padding-bottom: 10px 66 | color: #252A3A 67 | 68 | margin-bottom: 10px 69 | text-align: center 70 | 71 | span 72 | margin: 5px 73 | 74 | .val 75 | font-weight: 700 76 | 77 | .Pulse-eventInput 78 | input, button, label 79 | margin-right: 6px 80 | 81 | input[type=text] 82 | padding: 10px 83 | border: solid 1px #4B7197 84 | background-color: #fff 85 | color: #1B98F8 86 | appearance: none 87 | box-shadow: none 88 | transition: box-shadow 0.5s 89 | 90 | &:focus, &.focus 91 | box-shadow: 0 0 5px 1px #4B7197 92 | 93 | &::-webkit-input-placeholder 94 | color: #4B7197 95 | 96 | input[type=range] 97 | -webkit-appearance: none 98 | background-color: transparent 99 | vertical-align: middle 100 | margin: -3px 15px 0 101 | 102 | &::-webkit-slider-runnable-track 103 | width: 300px 104 | height: 3px 105 | background: #ddd 106 | border: none 107 | border-radius: 5px 108 | 109 | input[type=range]::-webkit-slider-thumb 110 | -webkit-appearance: none 111 | border: none 112 | height: 16px 113 | width: 16px 114 | border-radius: 50% 115 | background: rgba(27,152,148, 0.5) 116 | margin-top: -7px 117 | 118 | input.very-low[type=range]::-webkit-slider-thumb 119 | background: #84C8FB 120 | input.low[type=range]::-webkit-slider-thumb 121 | background: #6ABCFA 122 | input.mid[type=range]::-webkit-slider-thumb 123 | background: #4FB0F9 124 | input.high[type=range]::-webkit-slider-thumb 125 | background: #35A4F8 126 | input.very-high[type=range]::-webkit-slider-thumb 127 | background: #1B98F8 128 | 129 | input[type=range]:focus 130 | outline: none 131 | 132 | input[type=range]:focus::-webkit-slider-runnable-track 133 | background: #ccc 134 | 135 | button 136 | background-color: #1B98F8 137 | color: #fff 138 | 139 | .Pulse-eventList-list 140 | ul 141 | list-style-type: none 142 | margin: 0 143 | padding: 0 144 | li 145 | padding: 10px 20px 146 | background-color: #F8F8F8 147 | 148 | li:first-child 149 | border-top-left-radius: 10px 150 | border-top-right-radius: 10px 151 | 152 | li:last-child 153 | border-bottom-left-radius: 10px 154 | border-bottom-right-radius: 10px 155 | box-shadow: 0 3px 12px -8px #4B7197 156 | 157 | li.odd 158 | background-color: #F2F2F2 159 | 160 | form 161 | margin: 0 162 | 163 | fieldset 164 | padding: 0 165 | 166 | .Pulse-eventItem 167 | p 168 | margin-top: 6px 169 | 170 | &.empty 171 | p 172 | margin-top: 0px 173 | text-align: center 174 | color: #cdcdcd 175 | 176 | &.rowNumber 177 | float: left 178 | color: #cdcdcd 179 | width: 20px 180 | padding-right: 5px 181 | 182 | p.title 183 | float: left 184 | color: #4B7197 185 | cursor: pointer 186 | 187 | p.title:hover 188 | text-decoration: underline 189 | 190 | p.outcome, p.created 191 | float: right 192 | padding-right: 15px 193 | color: #cdcdcd 194 | 195 | p.outcom 196 | font-weight: bold 197 | color: #252A3A 198 | 199 | .destroy 200 | background-color: #EE5656 201 | color: #fff 202 | 203 | &::before 204 | content: 'x' 205 | 206 | .destroy, .save 207 | float: right 208 | margin-right: 0 209 | 210 | &::after 211 | clear: both 212 | content: '.' 213 | display: block 214 | height: 0 215 | visibility: hidden -------------------------------------------------------------------------------- /style/pure.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Pure v0.6.0 3 | Copyright 2014 Yahoo! Inc. All rights reserved. 4 | Licensed under the BSD License. 5 | https://github.com/yahoo/pure/blob/master/LICENSE.md 6 | */ 7 | /*! 8 | normalize.css v^3.0 | MIT License | git.io/normalize 9 | Copyright (c) Nicolas Gallagher and Jonathan Neal 10 | */ 11 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 12 | 13 | /** 14 | * 1. Set default font family to sans-serif. 15 | * 2. Prevent iOS text size adjust after orientation change, without disabling 16 | * user zoom. 17 | */ 18 | 19 | html { 20 | font-family: sans-serif; /* 1 */ 21 | -ms-text-size-adjust: 100%; /* 2 */ 22 | -webkit-text-size-adjust: 100%; /* 2 */ 23 | } 24 | 25 | /** 26 | * Remove default margin. 27 | */ 28 | 29 | body { 30 | margin: 0; 31 | } 32 | 33 | /* HTML5 display definitions 34 | ========================================================================== */ 35 | 36 | /** 37 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 38 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 39 | * and Firefox. 40 | * Correct `block` display not defined for `main` in IE 11. 41 | */ 42 | 43 | article, 44 | aside, 45 | details, 46 | figcaption, 47 | figure, 48 | footer, 49 | header, 50 | hgroup, 51 | main, 52 | menu, 53 | nav, 54 | section, 55 | summary { 56 | display: block; 57 | } 58 | 59 | /** 60 | * 1. Correct `inline-block` display not defined in IE 8/9. 61 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 62 | */ 63 | 64 | audio, 65 | canvas, 66 | progress, 67 | video { 68 | display: inline-block; /* 1 */ 69 | vertical-align: baseline; /* 2 */ 70 | } 71 | 72 | /** 73 | * Prevent modern browsers from displaying `audio` without controls. 74 | * Remove excess height in iOS 5 devices. 75 | */ 76 | 77 | audio:not([controls]) { 78 | display: none; 79 | height: 0; 80 | } 81 | 82 | /** 83 | * Address `[hidden]` styling not present in IE 8/9/10. 84 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 85 | */ 86 | 87 | [hidden], 88 | template { 89 | display: none; 90 | } 91 | 92 | /* Links 93 | ========================================================================== */ 94 | 95 | /** 96 | * Remove the gray background color from active links in IE 10. 97 | */ 98 | 99 | a { 100 | background-color: transparent; 101 | } 102 | 103 | /** 104 | * Improve readability when focused and also mouse hovered in all browsers. 105 | */ 106 | 107 | a:active, 108 | a:hover { 109 | outline: 0; 110 | } 111 | 112 | /* Text-level semantics 113 | ========================================================================== */ 114 | 115 | /** 116 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 117 | */ 118 | 119 | abbr[title] { 120 | border-bottom: 1px dotted; 121 | } 122 | 123 | /** 124 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 125 | */ 126 | 127 | b, 128 | strong { 129 | font-weight: bold; 130 | } 131 | 132 | /** 133 | * Address styling not present in Safari and Chrome. 134 | */ 135 | 136 | dfn { 137 | font-style: italic; 138 | } 139 | 140 | /** 141 | * Address variable `h1` font-size and margin within `section` and `article` 142 | * contexts in Firefox 4+, Safari, and Chrome. 143 | */ 144 | 145 | h1 { 146 | font-size: 2em; 147 | margin: 0.67em 0; 148 | } 149 | 150 | /** 151 | * Address styling not present in IE 8/9. 152 | */ 153 | 154 | mark { 155 | background: #ff0; 156 | color: #000; 157 | } 158 | 159 | /** 160 | * Address inconsistent and variable font size in all browsers. 161 | */ 162 | 163 | small { 164 | font-size: 80%; 165 | } 166 | 167 | /** 168 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 169 | */ 170 | 171 | sub, 172 | sup { 173 | font-size: 75%; 174 | line-height: 0; 175 | position: relative; 176 | vertical-align: baseline; 177 | } 178 | 179 | sup { 180 | top: -0.5em; 181 | } 182 | 183 | sub { 184 | bottom: -0.25em; 185 | } 186 | 187 | /* Embedded content 188 | ========================================================================== */ 189 | 190 | /** 191 | * Remove border when inside `a` element in IE 8/9/10. 192 | */ 193 | 194 | img { 195 | border: 0; 196 | } 197 | 198 | /** 199 | * Correct overflow not hidden in IE 9/10/11. 200 | */ 201 | 202 | svg:not(:root) { 203 | overflow: hidden; 204 | } 205 | 206 | /* Grouping content 207 | ========================================================================== */ 208 | 209 | /** 210 | * Address margin not present in IE 8/9 and Safari. 211 | */ 212 | 213 | figure { 214 | margin: 1em 40px; 215 | } 216 | 217 | /** 218 | * Address differences between Firefox and other browsers. 219 | */ 220 | 221 | hr { 222 | -moz-box-sizing: content-box; 223 | box-sizing: content-box; 224 | height: 0; 225 | } 226 | 227 | /** 228 | * Contain overflow in all browsers. 229 | */ 230 | 231 | pre { 232 | overflow: auto; 233 | } 234 | 235 | /** 236 | * Address odd `em`-unit font size rendering in all browsers. 237 | */ 238 | 239 | code, 240 | kbd, 241 | pre, 242 | samp { 243 | font-family: monospace, monospace; 244 | font-size: 1em; 245 | } 246 | 247 | /* Forms 248 | ========================================================================== */ 249 | 250 | /** 251 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 252 | * styling of `select`, unless a `border` property is set. 253 | */ 254 | 255 | /** 256 | * 1. Correct color not being inherited. 257 | * Known issue: affects color of disabled elements. 258 | * 2. Correct font properties not being inherited. 259 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 260 | */ 261 | 262 | button, 263 | input, 264 | optgroup, 265 | select, 266 | textarea { 267 | color: inherit; /* 1 */ 268 | font: inherit; /* 2 */ 269 | margin: 0; /* 3 */ 270 | } 271 | 272 | /** 273 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 274 | */ 275 | 276 | button { 277 | overflow: visible; 278 | } 279 | 280 | /** 281 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 282 | * All other form control elements do not inherit `text-transform` values. 283 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 284 | * Correct `select` style inheritance in Firefox. 285 | */ 286 | 287 | button, 288 | select { 289 | text-transform: none; 290 | } 291 | 292 | /** 293 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 294 | * and `video` controls. 295 | * 2. Correct inability to style clickable `input` types in iOS. 296 | * 3. Improve usability and consistency of cursor style between image-type 297 | * `input` and others. 298 | */ 299 | 300 | button, 301 | html input[type="button"], /* 1 */ 302 | input[type="reset"], 303 | input[type="submit"] { 304 | -webkit-appearance: button; /* 2 */ 305 | cursor: pointer; /* 3 */ 306 | } 307 | 308 | /** 309 | * Re-set default cursor for disabled elements. 310 | */ 311 | 312 | button[disabled], 313 | html input[disabled] { 314 | cursor: default; 315 | } 316 | 317 | /** 318 | * Remove inner padding and border in Firefox 4+. 319 | */ 320 | 321 | button::-moz-focus-inner, 322 | input::-moz-focus-inner { 323 | border: 0; 324 | padding: 0; 325 | } 326 | 327 | /** 328 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 329 | * the UA stylesheet. 330 | */ 331 | 332 | input { 333 | line-height: normal; 334 | } 335 | 336 | /** 337 | * It's recommended that you don't attempt to style these elements. 338 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 339 | * 340 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 341 | * 2. Remove excess padding in IE 8/9/10. 342 | */ 343 | 344 | input[type="checkbox"], 345 | input[type="radio"] { 346 | box-sizing: border-box; /* 1 */ 347 | padding: 0; /* 2 */ 348 | } 349 | 350 | /** 351 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 352 | * `font-size` values of the `input`, it causes the cursor style of the 353 | * decrement button to change from `default` to `text`. 354 | */ 355 | 356 | input[type="number"]::-webkit-inner-spin-button, 357 | input[type="number"]::-webkit-outer-spin-button { 358 | height: auto; 359 | } 360 | 361 | /** 362 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 363 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 364 | * (include `-moz` to future-proof). 365 | */ 366 | 367 | input[type="search"] { 368 | -webkit-appearance: textfield; /* 1 */ 369 | -moz-box-sizing: content-box; 370 | -webkit-box-sizing: content-box; /* 2 */ 371 | box-sizing: content-box; 372 | } 373 | 374 | /** 375 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 376 | * Safari (but not Chrome) clips the cancel button when the search input has 377 | * padding (and `textfield` appearance). 378 | */ 379 | 380 | input[type="search"]::-webkit-search-cancel-button, 381 | input[type="search"]::-webkit-search-decoration { 382 | -webkit-appearance: none; 383 | } 384 | 385 | /** 386 | * Define consistent border, margin, and padding. 387 | */ 388 | 389 | fieldset { 390 | border: 1px solid #c0c0c0; 391 | margin: 0 2px; 392 | padding: 0.35em 0.625em 0.75em; 393 | } 394 | 395 | /** 396 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 397 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 398 | */ 399 | 400 | legend { 401 | border: 0; /* 1 */ 402 | padding: 0; /* 2 */ 403 | } 404 | 405 | /** 406 | * Remove default vertical scrollbar in IE 8/9/10/11. 407 | */ 408 | 409 | textarea { 410 | overflow: auto; 411 | } 412 | 413 | /** 414 | * Don't inherit the `font-weight` (applied by a rule above). 415 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 416 | */ 417 | 418 | optgroup { 419 | font-weight: bold; 420 | } 421 | 422 | /* Tables 423 | ========================================================================== */ 424 | 425 | /** 426 | * Remove most spacing between table cells. 427 | */ 428 | 429 | table { 430 | border-collapse: collapse; 431 | border-spacing: 0; 432 | } 433 | 434 | td, 435 | th { 436 | padding: 0; 437 | } 438 | 439 | /*csslint important:false*/ 440 | 441 | /* ========================================================================== 442 | Pure Base Extras 443 | ========================================================================== */ 444 | 445 | /** 446 | * Extra rules that Pure adds on top of Normalize.css 447 | */ 448 | 449 | /** 450 | * Always hide an element when it has the `hidden` HTML attribute. 451 | */ 452 | 453 | .hidden, 454 | [hidden] { 455 | display: none !important; 456 | } 457 | 458 | /** 459 | * Add this class to an image to make it fit within it's fluid parent wrapper while maintaining 460 | * aspect ratio. 461 | */ 462 | .pure-img { 463 | max-width: 100%; 464 | height: auto; 465 | display: block; 466 | } 467 | 468 | /*csslint regex-selectors:false, known-properties:false, duplicate-properties:false*/ 469 | 470 | .pure-g { 471 | letter-spacing: -0.31em; /* Webkit: collapse white-space between units */ 472 | *letter-spacing: normal; /* reset IE < 8 */ 473 | *word-spacing: -0.43em; /* IE < 8: collapse white-space between units */ 474 | text-rendering: optimizespeed; /* Webkit: fixes text-rendering: optimizeLegibility */ 475 | 476 | /* 477 | Sets the font stack to fonts known to work properly with the above letter 478 | and word spacings. See: https://github.com/yahoo/pure/issues/41/ 479 | 480 | The following font stack makes Pure Grids work on all known environments. 481 | 482 | * FreeSans: Ships with many Linux distros, including Ubuntu 483 | 484 | * Arimo: Ships with Chrome OS. Arimo has to be defined before Helvetica and 485 | Arial to get picked up by the browser, even though neither is available 486 | in Chrome OS. 487 | 488 | * Droid Sans: Ships with all versions of Android. 489 | 490 | * Helvetica, Arial, sans-serif: Common font stack on OS X and Windows. 491 | */ 492 | font-family: FreeSans, Arimo, "Droid Sans", Helvetica, Arial, sans-serif; 493 | 494 | /* 495 | Use flexbox when possible to avoid `letter-spacing` side-effects. 496 | 497 | NOTE: Firefox (as of 25) does not currently support flex-wrap, so the 498 | `-moz-` prefix version is omitted. 499 | */ 500 | 501 | display: -webkit-flex; 502 | -webkit-flex-flow: row wrap; 503 | 504 | /* IE10 uses display: flexbox */ 505 | display: -ms-flexbox; 506 | -ms-flex-flow: row wrap; 507 | 508 | /* Prevents distributing space between rows */ 509 | -ms-align-content: flex-start; 510 | -webkit-align-content: flex-start; 511 | align-content: flex-start; 512 | } 513 | 514 | /* Opera as of 12 on Windows needs word-spacing. 515 | The ".opera-only" selector is used to prevent actual prefocus styling 516 | and is not required in markup. 517 | */ 518 | .opera-only :-o-prefocus, 519 | .pure-g { 520 | word-spacing: -0.43em; 521 | } 522 | 523 | .pure-u { 524 | display: inline-block; 525 | *display: inline; /* IE < 8: fake inline-block */ 526 | zoom: 1; 527 | letter-spacing: normal; 528 | word-spacing: normal; 529 | vertical-align: top; 530 | text-rendering: auto; 531 | } 532 | 533 | /* 534 | Resets the font family back to the OS/browser's default sans-serif font, 535 | this the same font stack that Normalize.css sets for the `body`. 536 | */ 537 | .pure-g [class *= "pure-u"] { 538 | font-family: sans-serif; 539 | } 540 | 541 | .pure-u-1, 542 | .pure-u-1-1, 543 | .pure-u-1-2, 544 | .pure-u-1-3, 545 | .pure-u-2-3, 546 | .pure-u-1-4, 547 | .pure-u-3-4, 548 | .pure-u-1-5, 549 | .pure-u-2-5, 550 | .pure-u-3-5, 551 | .pure-u-4-5, 552 | .pure-u-5-5, 553 | .pure-u-1-6, 554 | .pure-u-5-6, 555 | .pure-u-1-8, 556 | .pure-u-3-8, 557 | .pure-u-5-8, 558 | .pure-u-7-8, 559 | .pure-u-1-12, 560 | .pure-u-5-12, 561 | .pure-u-7-12, 562 | .pure-u-11-12, 563 | .pure-u-1-24, 564 | .pure-u-2-24, 565 | .pure-u-3-24, 566 | .pure-u-4-24, 567 | .pure-u-5-24, 568 | .pure-u-6-24, 569 | .pure-u-7-24, 570 | .pure-u-8-24, 571 | .pure-u-9-24, 572 | .pure-u-10-24, 573 | .pure-u-11-24, 574 | .pure-u-12-24, 575 | .pure-u-13-24, 576 | .pure-u-14-24, 577 | .pure-u-15-24, 578 | .pure-u-16-24, 579 | .pure-u-17-24, 580 | .pure-u-18-24, 581 | .pure-u-19-24, 582 | .pure-u-20-24, 583 | .pure-u-21-24, 584 | .pure-u-22-24, 585 | .pure-u-23-24, 586 | .pure-u-24-24 { 587 | display: inline-block; 588 | *display: inline; 589 | zoom: 1; 590 | letter-spacing: normal; 591 | word-spacing: normal; 592 | vertical-align: top; 593 | text-rendering: auto; 594 | } 595 | 596 | .pure-u-1-24 { 597 | width: 4.1667%; 598 | *width: 4.1357%; 599 | } 600 | 601 | .pure-u-1-12, 602 | .pure-u-2-24 { 603 | width: 8.3333%; 604 | *width: 8.3023%; 605 | } 606 | 607 | .pure-u-1-8, 608 | .pure-u-3-24 { 609 | width: 12.5000%; 610 | *width: 12.4690%; 611 | } 612 | 613 | .pure-u-1-6, 614 | .pure-u-4-24 { 615 | width: 16.6667%; 616 | *width: 16.6357%; 617 | } 618 | 619 | .pure-u-1-5 { 620 | width: 20%; 621 | *width: 19.9690%; 622 | } 623 | 624 | .pure-u-5-24 { 625 | width: 20.8333%; 626 | *width: 20.8023%; 627 | } 628 | 629 | .pure-u-1-4, 630 | .pure-u-6-24 { 631 | width: 25%; 632 | *width: 24.9690%; 633 | } 634 | 635 | .pure-u-7-24 { 636 | width: 29.1667%; 637 | *width: 29.1357%; 638 | } 639 | 640 | .pure-u-1-3, 641 | .pure-u-8-24 { 642 | width: 33.3333%; 643 | *width: 33.3023%; 644 | } 645 | 646 | .pure-u-3-8, 647 | .pure-u-9-24 { 648 | width: 37.5000%; 649 | *width: 37.4690%; 650 | } 651 | 652 | .pure-u-2-5 { 653 | width: 40%; 654 | *width: 39.9690%; 655 | } 656 | 657 | .pure-u-5-12, 658 | .pure-u-10-24 { 659 | width: 41.6667%; 660 | *width: 41.6357%; 661 | } 662 | 663 | .pure-u-11-24 { 664 | width: 45.8333%; 665 | *width: 45.8023%; 666 | } 667 | 668 | .pure-u-1-2, 669 | .pure-u-12-24 { 670 | width: 50%; 671 | *width: 49.9690%; 672 | } 673 | 674 | .pure-u-13-24 { 675 | width: 54.1667%; 676 | *width: 54.1357%; 677 | } 678 | 679 | .pure-u-7-12, 680 | .pure-u-14-24 { 681 | width: 58.3333%; 682 | *width: 58.3023%; 683 | } 684 | 685 | .pure-u-3-5 { 686 | width: 60%; 687 | *width: 59.9690%; 688 | } 689 | 690 | .pure-u-5-8, 691 | .pure-u-15-24 { 692 | width: 62.5000%; 693 | *width: 62.4690%; 694 | } 695 | 696 | .pure-u-2-3, 697 | .pure-u-16-24 { 698 | width: 66.6667%; 699 | *width: 66.6357%; 700 | } 701 | 702 | .pure-u-17-24 { 703 | width: 70.8333%; 704 | *width: 70.8023%; 705 | } 706 | 707 | .pure-u-3-4, 708 | .pure-u-18-24 { 709 | width: 75%; 710 | *width: 74.9690%; 711 | } 712 | 713 | .pure-u-19-24 { 714 | width: 79.1667%; 715 | *width: 79.1357%; 716 | } 717 | 718 | .pure-u-4-5 { 719 | width: 80%; 720 | *width: 79.9690%; 721 | } 722 | 723 | .pure-u-5-6, 724 | .pure-u-20-24 { 725 | width: 83.3333%; 726 | *width: 83.3023%; 727 | } 728 | 729 | .pure-u-7-8, 730 | .pure-u-21-24 { 731 | width: 87.5000%; 732 | *width: 87.4690%; 733 | } 734 | 735 | .pure-u-11-12, 736 | .pure-u-22-24 { 737 | width: 91.6667%; 738 | *width: 91.6357%; 739 | } 740 | 741 | .pure-u-23-24 { 742 | width: 95.8333%; 743 | *width: 95.8023%; 744 | } 745 | 746 | .pure-u-1, 747 | .pure-u-1-1, 748 | .pure-u-5-5, 749 | .pure-u-24-24 { 750 | width: 100%; 751 | } 752 | .pure-button { 753 | /* Structure */ 754 | display: inline-block; 755 | zoom: 1; 756 | line-height: normal; 757 | white-space: nowrap; 758 | vertical-align: middle; 759 | text-align: center; 760 | cursor: pointer; 761 | -webkit-user-drag: none; 762 | -webkit-user-select: none; 763 | -moz-user-select: none; 764 | -ms-user-select: none; 765 | user-select: none; 766 | -webkit-box-sizing: border-box; 767 | -moz-box-sizing: border-box; 768 | box-sizing: border-box; 769 | } 770 | 771 | /* Firefox: Get rid of the inner focus border */ 772 | .pure-button::-moz-focus-inner { 773 | padding: 0; 774 | border: 0; 775 | } 776 | 777 | /*csslint outline-none:false*/ 778 | 779 | .pure-button { 780 | font-family: inherit; 781 | font-size: 100%; 782 | padding: 0.5em 1em; 783 | color: #444; /* rgba not supported (IE 8) */ 784 | color: rgba(0, 0, 0, 0.80); /* rgba supported */ 785 | border: 1px solid #999; /*IE 6/7/8*/ 786 | border: none rgba(0, 0, 0, 0); /*IE9 + everything else*/ 787 | background-color: #E6E6E6; 788 | text-decoration: none; 789 | border-radius: 2px; 790 | } 791 | 792 | .pure-button-hover, 793 | .pure-button:hover, 794 | .pure-button:focus { 795 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#1a000000',GradientType=0); 796 | background-image: -webkit-gradient(linear, 0 0, 0 100%, from(transparent), color-stop(40%, rgba(0,0,0, 0.05)), to(rgba(0,0,0, 0.10))); 797 | background-image: -webkit-linear-gradient(transparent, rgba(0,0,0, 0.05) 40%, rgba(0,0,0, 0.10)); 798 | background-image: -moz-linear-gradient(top, rgba(0,0,0, 0.05) 0%, rgba(0,0,0, 0.10)); 799 | background-image: -o-linear-gradient(transparent, rgba(0,0,0, 0.05) 40%, rgba(0,0,0, 0.10)); 800 | background-image: linear-gradient(transparent, rgba(0,0,0, 0.05) 40%, rgba(0,0,0, 0.10)); 801 | } 802 | .pure-button:focus { 803 | outline: 0; 804 | } 805 | .pure-button-active, 806 | .pure-button:active { 807 | box-shadow: 0 0 0 1px rgba(0,0,0, 0.15) inset, 0 0 6px rgba(0,0,0, 0.20) inset; 808 | border-color: #000\9; 809 | } 810 | 811 | .pure-button[disabled], 812 | .pure-button-disabled, 813 | .pure-button-disabled:hover, 814 | .pure-button-disabled:focus, 815 | .pure-button-disabled:active { 816 | border: none; 817 | background-image: none; 818 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 819 | filter: alpha(opacity=40); 820 | -khtml-opacity: 0.40; 821 | -moz-opacity: 0.40; 822 | opacity: 0.40; 823 | cursor: not-allowed; 824 | box-shadow: none; 825 | } 826 | 827 | .pure-button-hidden { 828 | display: none; 829 | } 830 | 831 | /* Firefox: Get rid of the inner focus border */ 832 | .pure-button::-moz-focus-inner{ 833 | padding: 0; 834 | border: 0; 835 | } 836 | 837 | .pure-button-primary, 838 | .pure-button-selected, 839 | a.pure-button-primary, 840 | a.pure-button-selected { 841 | background-color: rgb(0, 120, 231); 842 | color: #fff; 843 | } 844 | 845 | /*csslint box-model:false*/ 846 | /* 847 | Box-model set to false because we're setting a height on select elements, which 848 | also have border and padding. This is done because some browsers don't render 849 | the padding. We explicitly set the box-model for select elements to border-box, 850 | so we can ignore the csslint warning. 851 | */ 852 | 853 | .pure-form input[type="text"], 854 | .pure-form input[type="password"], 855 | .pure-form input[type="email"], 856 | .pure-form input[type="url"], 857 | .pure-form input[type="date"], 858 | .pure-form input[type="month"], 859 | .pure-form input[type="time"], 860 | .pure-form input[type="datetime"], 861 | .pure-form input[type="datetime-local"], 862 | .pure-form input[type="week"], 863 | .pure-form input[type="number"], 864 | .pure-form input[type="search"], 865 | .pure-form input[type="tel"], 866 | .pure-form input[type="color"], 867 | .pure-form select, 868 | .pure-form textarea { 869 | padding: 0.5em 0.6em; 870 | display: inline-block; 871 | border: 1px solid #ccc; 872 | box-shadow: inset 0 1px 3px #ddd; 873 | border-radius: 4px; 874 | vertical-align: middle; 875 | -webkit-box-sizing: border-box; 876 | -moz-box-sizing: border-box; 877 | box-sizing: border-box; 878 | } 879 | 880 | /* 881 | Need to separate out the :not() selector from the rest of the CSS 2.1 selectors 882 | since IE8 won't execute CSS that contains a CSS3 selector. 883 | */ 884 | .pure-form input:not([type]) { 885 | padding: 0.5em 0.6em; 886 | display: inline-block; 887 | border: 1px solid #ccc; 888 | box-shadow: inset 0 1px 3px #ddd; 889 | border-radius: 4px; 890 | -webkit-box-sizing: border-box; 891 | -moz-box-sizing: border-box; 892 | box-sizing: border-box; 893 | } 894 | 895 | 896 | /* Chrome (as of v.32/34 on OS X) needs additional room for color to display. */ 897 | /* May be able to remove this tweak as color inputs become more standardized across browsers. */ 898 | .pure-form input[type="color"] { 899 | padding: 0.2em 0.5em; 900 | } 901 | 902 | 903 | .pure-form input[type="text"]:focus, 904 | .pure-form input[type="password"]:focus, 905 | .pure-form input[type="email"]:focus, 906 | .pure-form input[type="url"]:focus, 907 | .pure-form input[type="date"]:focus, 908 | .pure-form input[type="month"]:focus, 909 | .pure-form input[type="time"]:focus, 910 | .pure-form input[type="datetime"]:focus, 911 | .pure-form input[type="datetime-local"]:focus, 912 | .pure-form input[type="week"]:focus, 913 | .pure-form input[type="number"]:focus, 914 | .pure-form input[type="search"]:focus, 915 | .pure-form input[type="tel"]:focus, 916 | .pure-form input[type="color"]:focus, 917 | .pure-form select:focus, 918 | .pure-form textarea:focus { 919 | outline: 0; 920 | border-color: #129FEA; 921 | } 922 | 923 | /* 924 | Need to separate out the :not() selector from the rest of the CSS 2.1 selectors 925 | since IE8 won't execute CSS that contains a CSS3 selector. 926 | */ 927 | .pure-form input:not([type]):focus { 928 | outline: 0; 929 | border-color: #129FEA; 930 | } 931 | 932 | .pure-form input[type="file"]:focus, 933 | .pure-form input[type="radio"]:focus, 934 | .pure-form input[type="checkbox"]:focus { 935 | outline: thin solid #129FEA; 936 | outline: 1px auto #129FEA; 937 | } 938 | .pure-form .pure-checkbox, 939 | .pure-form .pure-radio { 940 | margin: 0.5em 0; 941 | display: block; 942 | } 943 | 944 | .pure-form input[type="text"][disabled], 945 | .pure-form input[type="password"][disabled], 946 | .pure-form input[type="email"][disabled], 947 | .pure-form input[type="url"][disabled], 948 | .pure-form input[type="date"][disabled], 949 | .pure-form input[type="month"][disabled], 950 | .pure-form input[type="time"][disabled], 951 | .pure-form input[type="datetime"][disabled], 952 | .pure-form input[type="datetime-local"][disabled], 953 | .pure-form input[type="week"][disabled], 954 | .pure-form input[type="number"][disabled], 955 | .pure-form input[type="search"][disabled], 956 | .pure-form input[type="tel"][disabled], 957 | .pure-form input[type="color"][disabled], 958 | .pure-form select[disabled], 959 | .pure-form textarea[disabled] { 960 | cursor: not-allowed; 961 | background-color: #eaeded; 962 | color: #cad2d3; 963 | } 964 | 965 | /* 966 | Need to separate out the :not() selector from the rest of the CSS 2.1 selectors 967 | since IE8 won't execute CSS that contains a CSS3 selector. 968 | */ 969 | .pure-form input:not([type])[disabled] { 970 | cursor: not-allowed; 971 | background-color: #eaeded; 972 | color: #cad2d3; 973 | } 974 | .pure-form input[readonly], 975 | .pure-form select[readonly], 976 | .pure-form textarea[readonly] { 977 | background-color: #eee; /* menu hover bg color */ 978 | color: #777; /* menu text color */ 979 | border-color: #ccc; 980 | } 981 | 982 | .pure-form input:focus:invalid, 983 | .pure-form textarea:focus:invalid, 984 | .pure-form select:focus:invalid { 985 | color: #b94a48; 986 | border-color: #e9322d; 987 | } 988 | .pure-form input[type="file"]:focus:invalid:focus, 989 | .pure-form input[type="radio"]:focus:invalid:focus, 990 | .pure-form input[type="checkbox"]:focus:invalid:focus { 991 | outline-color: #e9322d; 992 | } 993 | .pure-form select { 994 | /* Normalizes the height; padding is not sufficient. */ 995 | height: 2.25em; 996 | border: 1px solid #ccc; 997 | background-color: white; 998 | } 999 | .pure-form select[multiple] { 1000 | height: auto; 1001 | } 1002 | .pure-form label { 1003 | margin: 0.5em 0 0.2em; 1004 | } 1005 | .pure-form fieldset { 1006 | margin: 0; 1007 | padding: 0.35em 0 0.75em; 1008 | border: 0; 1009 | } 1010 | .pure-form legend { 1011 | display: block; 1012 | width: 100%; 1013 | padding: 0.3em 0; 1014 | margin-bottom: 0.3em; 1015 | color: #333; 1016 | border-bottom: 1px solid #e5e5e5; 1017 | } 1018 | 1019 | .pure-form-stacked input[type="text"], 1020 | .pure-form-stacked input[type="password"], 1021 | .pure-form-stacked input[type="email"], 1022 | .pure-form-stacked input[type="url"], 1023 | .pure-form-stacked input[type="date"], 1024 | .pure-form-stacked input[type="month"], 1025 | .pure-form-stacked input[type="time"], 1026 | .pure-form-stacked input[type="datetime"], 1027 | .pure-form-stacked input[type="datetime-local"], 1028 | .pure-form-stacked input[type="week"], 1029 | .pure-form-stacked input[type="number"], 1030 | .pure-form-stacked input[type="search"], 1031 | .pure-form-stacked input[type="tel"], 1032 | .pure-form-stacked input[type="color"], 1033 | .pure-form-stacked input[type="file"], 1034 | .pure-form-stacked select, 1035 | .pure-form-stacked label, 1036 | .pure-form-stacked textarea { 1037 | display: block; 1038 | margin: 0.25em 0; 1039 | } 1040 | 1041 | /* 1042 | Need to separate out the :not() selector from the rest of the CSS 2.1 selectors 1043 | since IE8 won't execute CSS that contains a CSS3 selector. 1044 | */ 1045 | .pure-form-stacked input:not([type]) { 1046 | display: block; 1047 | margin: 0.25em 0; 1048 | } 1049 | .pure-form-aligned input, 1050 | .pure-form-aligned textarea, 1051 | .pure-form-aligned select, 1052 | /* NOTE: pure-help-inline is deprecated. Use .pure-form-message-inline instead. */ 1053 | .pure-form-aligned .pure-help-inline, 1054 | .pure-form-message-inline { 1055 | display: inline-block; 1056 | *display: inline; 1057 | *zoom: 1; 1058 | vertical-align: middle; 1059 | } 1060 | .pure-form-aligned textarea { 1061 | vertical-align: top; 1062 | } 1063 | 1064 | /* Aligned Forms */ 1065 | .pure-form-aligned .pure-control-group { 1066 | margin-bottom: 0.5em; 1067 | } 1068 | .pure-form-aligned .pure-control-group label { 1069 | text-align: right; 1070 | display: inline-block; 1071 | vertical-align: middle; 1072 | width: 10em; 1073 | margin: 0 1em 0 0; 1074 | } 1075 | .pure-form-aligned .pure-controls { 1076 | margin: 1.5em 0 0 11em; 1077 | } 1078 | 1079 | /* Rounded Inputs */ 1080 | .pure-form input.pure-input-rounded, 1081 | .pure-form .pure-input-rounded { 1082 | border-radius: 2em; 1083 | padding: 0.5em 1em; 1084 | } 1085 | 1086 | /* Grouped Inputs */ 1087 | .pure-form .pure-group fieldset { 1088 | margin-bottom: 10px; 1089 | } 1090 | .pure-form .pure-group input, 1091 | .pure-form .pure-group textarea { 1092 | display: block; 1093 | padding: 10px; 1094 | margin: 0 0 -1px; 1095 | border-radius: 0; 1096 | position: relative; 1097 | top: -1px; 1098 | } 1099 | .pure-form .pure-group input:focus, 1100 | .pure-form .pure-group textarea:focus { 1101 | z-index: 3; 1102 | } 1103 | .pure-form .pure-group input:first-child, 1104 | .pure-form .pure-group textarea:first-child { 1105 | top: 1px; 1106 | border-radius: 4px 4px 0 0; 1107 | margin: 0; 1108 | } 1109 | .pure-form .pure-group input:first-child:last-child, 1110 | .pure-form .pure-group textarea:first-child:last-child { 1111 | top: 1px; 1112 | border-radius: 4px; 1113 | margin: 0; 1114 | } 1115 | .pure-form .pure-group input:last-child, 1116 | .pure-form .pure-group textarea:last-child { 1117 | top: -2px; 1118 | border-radius: 0 0 4px 4px; 1119 | margin: 0; 1120 | } 1121 | .pure-form .pure-group button { 1122 | margin: 0.35em 0; 1123 | } 1124 | 1125 | .pure-form .pure-input-1 { 1126 | width: 100%; 1127 | } 1128 | .pure-form .pure-input-2-3 { 1129 | width: 66%; 1130 | } 1131 | .pure-form .pure-input-1-2 { 1132 | width: 50%; 1133 | } 1134 | .pure-form .pure-input-1-3 { 1135 | width: 33%; 1136 | } 1137 | .pure-form .pure-input-1-4 { 1138 | width: 25%; 1139 | } 1140 | 1141 | /* Inline help for forms */ 1142 | /* NOTE: pure-help-inline is deprecated. Use .pure-form-message-inline instead. */ 1143 | .pure-form .pure-help-inline, 1144 | .pure-form-message-inline { 1145 | display: inline-block; 1146 | padding-left: 0.3em; 1147 | color: #666; 1148 | vertical-align: middle; 1149 | font-size: 0.875em; 1150 | } 1151 | 1152 | /* Block help for forms */ 1153 | .pure-form-message { 1154 | display: block; 1155 | color: #666; 1156 | font-size: 0.875em; 1157 | } 1158 | 1159 | @media only screen and (max-width : 480px) { 1160 | .pure-form button[type="submit"] { 1161 | margin: 0.7em 0 0; 1162 | } 1163 | 1164 | .pure-form input:not([type]), 1165 | .pure-form input[type="text"], 1166 | .pure-form input[type="password"], 1167 | .pure-form input[type="email"], 1168 | .pure-form input[type="url"], 1169 | .pure-form input[type="date"], 1170 | .pure-form input[type="month"], 1171 | .pure-form input[type="time"], 1172 | .pure-form input[type="datetime"], 1173 | .pure-form input[type="datetime-local"], 1174 | .pure-form input[type="week"], 1175 | .pure-form input[type="number"], 1176 | .pure-form input[type="search"], 1177 | .pure-form input[type="tel"], 1178 | .pure-form input[type="color"], 1179 | .pure-form label { 1180 | margin-bottom: 0.3em; 1181 | display: block; 1182 | } 1183 | 1184 | .pure-group input:not([type]), 1185 | .pure-group input[type="text"], 1186 | .pure-group input[type="password"], 1187 | .pure-group input[type="email"], 1188 | .pure-group input[type="url"], 1189 | .pure-group input[type="date"], 1190 | .pure-group input[type="month"], 1191 | .pure-group input[type="time"], 1192 | .pure-group input[type="datetime"], 1193 | .pure-group input[type="datetime-local"], 1194 | .pure-group input[type="week"], 1195 | .pure-group input[type="number"], 1196 | .pure-group input[type="search"], 1197 | .pure-group input[type="tel"], 1198 | .pure-group input[type="color"] { 1199 | margin-bottom: 0; 1200 | } 1201 | 1202 | .pure-form-aligned .pure-control-group label { 1203 | margin-bottom: 0.3em; 1204 | text-align: left; 1205 | display: block; 1206 | width: 100%; 1207 | } 1208 | 1209 | .pure-form-aligned .pure-controls { 1210 | margin: 1.5em 0 0 0; 1211 | } 1212 | 1213 | /* NOTE: pure-help-inline is deprecated. Use .pure-form-message-inline instead. */ 1214 | .pure-form .pure-help-inline, 1215 | .pure-form-message-inline, 1216 | .pure-form-message { 1217 | display: block; 1218 | font-size: 0.75em; 1219 | /* Increased bottom padding to make it group with its related input element. */ 1220 | padding: 0.2em 0 0.8em; 1221 | } 1222 | } 1223 | 1224 | /*csslint adjoining-classes: false, box-model:false*/ 1225 | .pure-menu { 1226 | -webkit-box-sizing: border-box; 1227 | -moz-box-sizing: border-box; 1228 | box-sizing: border-box; 1229 | } 1230 | 1231 | .pure-menu-fixed { 1232 | position: fixed; 1233 | left: 0; 1234 | top: 0; 1235 | z-index: 3; 1236 | } 1237 | 1238 | .pure-menu-list, 1239 | .pure-menu-item { 1240 | position: relative; 1241 | } 1242 | 1243 | .pure-menu-list { 1244 | list-style: none; 1245 | margin: 0; 1246 | padding: 0; 1247 | } 1248 | 1249 | .pure-menu-item { 1250 | padding: 0; 1251 | margin: 0; 1252 | height: 100%; 1253 | } 1254 | 1255 | .pure-menu-link, 1256 | .pure-menu-heading { 1257 | display: block; 1258 | text-decoration: none; 1259 | white-space: nowrap; 1260 | } 1261 | 1262 | /* HORIZONTAL MENU */ 1263 | .pure-menu-horizontal { 1264 | width: 100%; 1265 | white-space: nowrap; 1266 | } 1267 | 1268 | .pure-menu-horizontal .pure-menu-list { 1269 | display: inline-block; 1270 | } 1271 | 1272 | /* Initial menus should be inline-block so that they are horizontal */ 1273 | .pure-menu-horizontal .pure-menu-item, 1274 | .pure-menu-horizontal .pure-menu-heading, 1275 | .pure-menu-horizontal .pure-menu-separator { 1276 | display: inline-block; 1277 | *display: inline; 1278 | zoom: 1; 1279 | vertical-align: middle; 1280 | } 1281 | 1282 | /* Submenus should still be display: block; */ 1283 | .pure-menu-item .pure-menu-item { 1284 | display: block; 1285 | } 1286 | 1287 | .pure-menu-children { 1288 | display: none; 1289 | position: absolute; 1290 | left: 100%; 1291 | top: 0; 1292 | margin: 0; 1293 | padding: 0; 1294 | z-index: 3; 1295 | } 1296 | 1297 | .pure-menu-horizontal .pure-menu-children { 1298 | left: 0; 1299 | top: auto; 1300 | width: inherit; 1301 | } 1302 | 1303 | .pure-menu-allow-hover:hover > .pure-menu-children, 1304 | .pure-menu-active > .pure-menu-children { 1305 | display: block; 1306 | position: absolute; 1307 | } 1308 | 1309 | /* Vertical Menus - show the dropdown arrow */ 1310 | .pure-menu-has-children > .pure-menu-link:after { 1311 | padding-left: 0.5em; 1312 | content: "\25B8"; 1313 | font-size: small; 1314 | } 1315 | 1316 | /* Horizontal Menus - show the dropdown arrow */ 1317 | .pure-menu-horizontal .pure-menu-has-children > .pure-menu-link:after { 1318 | content: "\25BE"; 1319 | } 1320 | 1321 | /* scrollable menus */ 1322 | .pure-menu-scrollable { 1323 | overflow-y: scroll; 1324 | overflow-x: hidden; 1325 | } 1326 | 1327 | .pure-menu-scrollable .pure-menu-list { 1328 | display: block; 1329 | } 1330 | 1331 | .pure-menu-horizontal.pure-menu-scrollable .pure-menu-list { 1332 | display: inline-block; 1333 | } 1334 | 1335 | .pure-menu-horizontal.pure-menu-scrollable { 1336 | white-space: nowrap; 1337 | overflow-y: hidden; 1338 | overflow-x: auto; 1339 | -ms-overflow-style: none; 1340 | -webkit-overflow-scrolling: touch; 1341 | /* a little extra padding for this style to allow for scrollbars */ 1342 | padding: .5em 0; 1343 | } 1344 | 1345 | .pure-menu-horizontal.pure-menu-scrollable::-webkit-scrollbar { 1346 | display: none; 1347 | } 1348 | 1349 | /* misc default styling */ 1350 | 1351 | .pure-menu-separator { 1352 | background-color: #ccc; 1353 | height: 1px; 1354 | margin: .3em 0; 1355 | } 1356 | 1357 | .pure-menu-horizontal .pure-menu-separator { 1358 | width: 1px; 1359 | height: 1.3em; 1360 | margin: 0 .3em ; 1361 | } 1362 | 1363 | .pure-menu-heading { 1364 | text-transform: uppercase; 1365 | color: #565d64; 1366 | } 1367 | 1368 | .pure-menu-link { 1369 | color: #777; 1370 | } 1371 | 1372 | .pure-menu-children { 1373 | background-color: #fff; 1374 | } 1375 | 1376 | .pure-menu-link, 1377 | .pure-menu-disabled, 1378 | .pure-menu-heading { 1379 | padding: .5em 1em; 1380 | } 1381 | 1382 | .pure-menu-disabled { 1383 | opacity: .5; 1384 | } 1385 | 1386 | .pure-menu-disabled .pure-menu-link:hover { 1387 | background-color: transparent; 1388 | } 1389 | 1390 | .pure-menu-active > .pure-menu-link, 1391 | .pure-menu-link:hover, 1392 | .pure-menu-link:focus { 1393 | background-color: #eee; 1394 | } 1395 | 1396 | .pure-menu-selected .pure-menu-link, 1397 | .pure-menu-selected .pure-menu-link:visited { 1398 | color: #000; 1399 | } 1400 | 1401 | .pure-table { 1402 | /* Remove spacing between table cells (from Normalize.css) */ 1403 | border-collapse: collapse; 1404 | border-spacing: 0; 1405 | empty-cells: show; 1406 | border: 1px solid #cbcbcb; 1407 | } 1408 | 1409 | .pure-table caption { 1410 | color: #000; 1411 | font: italic 85%/1 arial, sans-serif; 1412 | padding: 1em 0; 1413 | text-align: center; 1414 | } 1415 | 1416 | .pure-table td, 1417 | .pure-table th { 1418 | border-left: 1px solid #cbcbcb;/* inner column border */ 1419 | border-width: 0 0 0 1px; 1420 | font-size: inherit; 1421 | margin: 0; 1422 | overflow: visible; /*to make ths where the title is really long work*/ 1423 | padding: 0.5em 1em; /* cell padding */ 1424 | } 1425 | 1426 | /* Consider removing this next declaration block, as it causes problems when 1427 | there's a rowspan on the first cell. Case added to the tests. issue#432 */ 1428 | .pure-table td:first-child, 1429 | .pure-table th:first-child { 1430 | border-left-width: 0; 1431 | } 1432 | 1433 | .pure-table thead { 1434 | background-color: #e0e0e0; 1435 | color: #000; 1436 | text-align: left; 1437 | vertical-align: bottom; 1438 | } 1439 | 1440 | /* 1441 | striping: 1442 | even - #fff (white) 1443 | odd - #f2f2f2 (light gray) 1444 | */ 1445 | .pure-table td { 1446 | background-color: transparent; 1447 | } 1448 | .pure-table-odd td { 1449 | background-color: #f2f2f2; 1450 | } 1451 | 1452 | /* nth-child selector for modern browsers */ 1453 | .pure-table-striped tr:nth-child(2n-1) td { 1454 | background-color: #f2f2f2; 1455 | } 1456 | 1457 | /* BORDERED TABLES */ 1458 | .pure-table-bordered td { 1459 | border-bottom: 1px solid #cbcbcb; 1460 | } 1461 | .pure-table-bordered tbody > tr:last-child > td { 1462 | border-bottom-width: 0; 1463 | } 1464 | 1465 | 1466 | /* HORIZONTAL BORDERED TABLES */ 1467 | 1468 | .pure-table-horizontal td, 1469 | .pure-table-horizontal th { 1470 | border-width: 0 0 1px 0; 1471 | border-bottom: 1px solid #cbcbcb; 1472 | } 1473 | .pure-table-horizontal tbody > tr:last-child > td { 1474 | border-bottom-width: 0; 1475 | } 1476 | -------------------------------------------------------------------------------- /style/spinner.styl: -------------------------------------------------------------------------------- 1 | /* :not(:required) hides this rule from IE9 and below */ 2 | .Pulse-async-spinner:not(:required) 3 | animation: Pulse-async-spinner 1250ms infinite linear 4 | border: 5px solid #1B98F8 5 | border-right-color: transparent 6 | border-radius: 12px 7 | box-sizing: border-box 8 | display: inline-block 9 | position: relative 10 | overflow: hidden 11 | text-indent: -9999px 12 | width: 24px 13 | height: 24px 14 | 15 | @keyframes Pulse-async-spinner 16 | 0% 17 | transform: rotate(0deg) 18 | 100% 19 | transform: rotate(360deg) -------------------------------------------------------------------------------- /test/actions.test.js: -------------------------------------------------------------------------------- 1 | /* global describe */ 2 | /* global it */ 3 | /* global afterEach */ 4 | 5 | import sinon from 'sinon'; 6 | import chai from 'chai'; 7 | 8 | var expect = chai.expect; 9 | 10 | import configureStore from 'redux-mock-store'; 11 | import thunk from 'redux-thunk'; 12 | 13 | import { setUserId, loadEvents, __RewireAPI__ as actions } from '../universal/actions/PulseActions.js'; 14 | import * as types from '../universal/constants/ActionTypes'; 15 | 16 | describe('Actions', () => { 17 | afterEach(function() { 18 | actions.__ResetDependency__('request'); 19 | }); 20 | 21 | /** 22 | * Example of writing a test on a syncronous action creator 23 | */ 24 | describe('setUserId', () => { 25 | it('should return action with type SET_USER_ID and userId equal to 200', () => { 26 | let action = setUserId(200); 27 | expect(action.type).to.equal(types.SET_USER_ID); 28 | expect(action.userId).to.equal(200); 29 | }); 30 | 31 | it('should return action with type SET_USER_ID and userId equal to 6700102', () => { 32 | let action = setUserId(6700102); 33 | expect(action.type).to.equal(types.SET_USER_ID); 34 | expect(action.userId).to.equal(6700102); 35 | }); 36 | }); 37 | 38 | /** 39 | * Example of writing a test on an asyncronous action creator 40 | */ 41 | describe('loadEvents', () => { 42 | const mockStore = configureStore([thunk]); 43 | it('should trigger a LOAD_EVENTS_REQUEST and LOAD_EVENTS_SUCCESS action when succesful', () => { 44 | let requestMock = { 45 | get: () => ({ 46 | set: () => ({ 47 | end: (x) => x(null, { 48 | body: [ { name: 'Awesome', value: 54 } ] 49 | }) 50 | }) 51 | }) 52 | }; 53 | 54 | actions.__Rewire__('request', requestMock); 55 | 56 | let expectedActions = [ 57 | { type: 'LOAD_EVENTS_REQUEST' }, 58 | { type: 'LOAD_EVENTS_SUCCESS', events: [ { name: 'Awesome', value: 54 } ] } 59 | ]; 60 | 61 | let initialState = {pulseApp: { events: [], userId: 'baseUser'} }; 62 | let store = mockStore(initialState); 63 | 64 | store.dispatch(loadEvents()); 65 | 66 | const actualActions = store.getActions(); 67 | 68 | expect(actualActions).to.eql(expectedActions); 69 | }); 70 | 71 | it('should trigger a LOAD_EVENTS_REQUEST and LOAD_EVENTS_FAILURE action when unsuccessful', () => { 72 | let error = 'An Error Occurred!'; 73 | let requestMock = { 74 | get: () => ({ 75 | set: () => ({ 76 | end: (x) => x(error) 77 | }) 78 | }) 79 | }; 80 | 81 | actions.__Rewire__('request', requestMock); 82 | 83 | let expectedActions = [ 84 | { type: 'LOAD_EVENTS_REQUEST' }, 85 | { type: 'LOAD_EVENTS_FAILURE', error: error } 86 | ]; 87 | 88 | let initialState = {pulseApp: { events: [], userId: 'baseUser'} }; 89 | let store = mockStore(initialState); 90 | 91 | store.dispatch(loadEvents()); 92 | 93 | const actualActions = store.getActions(); 94 | 95 | expect(actualActions).to.eql(expectedActions); 96 | }); 97 | }); 98 | }); -------------------------------------------------------------------------------- /test/reducers.test.js: -------------------------------------------------------------------------------- 1 | /* global describe */ 2 | /* global it */ 3 | /* global afterEach */ 4 | 5 | import 'babel-polyfill'; // For use of Object.assign 6 | 7 | import sinon from 'sinon'; 8 | import chai from 'chai'; 9 | 10 | var expect = chai.expect; 11 | 12 | import reducer from '../universal/reducers'; 13 | import * as actions from '../universal/actions/PulseActions.js'; 14 | 15 | describe('Reducers', () => { 16 | 17 | /** 18 | * Example of writing a test on a reducing functiom 19 | */ 20 | describe('setUserId', () => { 21 | it('should set user id', () => { 22 | let initialStateForTest = { userId: null }; 23 | let userId = 234; 24 | let action = actions.setUserId(userId); 25 | 26 | expect(initialStateForTest.userId).to.be.null; 27 | 28 | let state = reducer(initialStateForTest, action); 29 | expect(state.userId).to.equal(userId); 30 | }); 31 | }); 32 | 33 | describe('addEvent', () => { 34 | describe('request', () => { 35 | it('should set isWorking to true', () => { 36 | let initialStateForTest = { isWorking: false }; 37 | let action = actions.addEventRequest(); 38 | 39 | expect(initialStateForTest.isWorking).to.be.false; 40 | 41 | let state = reducer(initialStateForTest, action); 42 | expect(state.isWorking).to.be.true; 43 | }); 44 | }); 45 | 46 | describe('success', () => { 47 | it('should set isWorking to false and add event to events', () => { 48 | let events = [ 49 | { id: 22, name: 'Entry', value: 20 } 50 | ]; 51 | let initialStateForTest = { isWorking: true, events: events }; 52 | let event = { id: 25, name: 'Another Entry', value: 50 }; 53 | 54 | let action = actions.addEventSuccess(event); 55 | 56 | expect(initialStateForTest.isWorking).to.be.true; 57 | expect(initialStateForTest.events.length).to.equal(events.length); 58 | 59 | 60 | let state = reducer(initialStateForTest, action); 61 | expect(state.isWorking).to.be.false; 62 | expect(state.events.length).to.equal(events.length + 1); 63 | }); 64 | }); 65 | 66 | describe('failure', () => { 67 | it('should set isWorking to false and error and not change events', () => { 68 | let events = [ 69 | { id: 22, name: 'Entry', value: 20 } 70 | ]; 71 | let initialStateForTest = { isWorking: true, events: events, error: null }; 72 | let error = 'some error'; 73 | 74 | let action = actions.addEventFailure(error); 75 | 76 | expect(initialStateForTest.isWorking).to.be.true; 77 | expect(initialStateForTest.error).to.be.null; 78 | expect(initialStateForTest.events.length).to.equal(events.length); 79 | 80 | 81 | let state = reducer(initialStateForTest, action); 82 | expect(state.isWorking).to.be.false; 83 | expect(state.error).to.equal(error); 84 | expect(state.events.length).to.equal(events.length); 85 | }); 86 | }); 87 | }); 88 | }); -------------------------------------------------------------------------------- /tests.webpack.js: -------------------------------------------------------------------------------- 1 | var context = require.context('./test', true, /.js$/); // Load files in /test with filename matching * .test.js 2 | context.keys().forEach(context); -------------------------------------------------------------------------------- /universal/actions/PulseActions.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/ActionTypes'; 2 | import request from 'superagent'; 3 | 4 | const serverUrl = ''; 5 | const eventsUrl = `${serverUrl}/api/0/events`; 6 | 7 | export function setUserId(userId) { 8 | return { 9 | type: types.SET_USER_ID, 10 | userId 11 | }; 12 | } 13 | 14 | export function loadEvents() { 15 | return dispatch => { 16 | dispatch(loadEventsRequest()); 17 | return request 18 | .get(eventsUrl) 19 | .set('Accept', 'application/json') 20 | .end((err, res) => { 21 | if (err) { 22 | dispatch(loadEventsFailure(err)); 23 | } else { 24 | dispatch(loadEventsSuccess(res.body)); 25 | } 26 | }); 27 | }; 28 | } 29 | 30 | export function loadEventsRequest() { 31 | return { 32 | type: types.LOAD_EVENTS_REQUEST 33 | }; 34 | } 35 | 36 | export function loadEventsSuccess(events) { 37 | return { 38 | type: types.LOAD_EVENTS_SUCCESS, 39 | events 40 | }; 41 | } 42 | 43 | export function loadEventsFailure(error) { 44 | return { 45 | type: types.LOAD_EVENTS_FAILURE, 46 | error 47 | }; 48 | } 49 | 50 | export function addEvent(event) { 51 | console.log('Add event', event); 52 | return dispatch => { 53 | dispatch(addEventRequest(event)); 54 | 55 | return request 56 | .post(eventsUrl) 57 | .send(event) 58 | .set('Accept', 'application/json') 59 | .end((err, res) => { 60 | if (err) { 61 | dispatch(addEventFailure(err, event)); 62 | } else { 63 | dispatch(addEventSuccess(res.body)); 64 | } 65 | }); 66 | }; 67 | } 68 | 69 | export function addEventRequest(event) { 70 | return { 71 | type: types.ADD_EVENT_REQUEST, 72 | event 73 | }; 74 | } 75 | 76 | export function addEventSuccess(event) { 77 | return { 78 | type: types.ADD_EVENT_SUCCESS, 79 | event 80 | }; 81 | } 82 | 83 | export function addEventFailure(error, event) { 84 | return { 85 | type: types.ADD_EVENT_FAILURE, 86 | error 87 | }; 88 | } 89 | 90 | export function deleteEvent(event) { 91 | return dispatch => { 92 | dispatch(deleteEventRequest(event)); 93 | 94 | return request 95 | .del(eventsUrl + '/' + event.id) 96 | .set('Accept', 'application/json') 97 | .end((err, res) => { 98 | if (err) { 99 | dispatch(deleteEventFailure(err, event)); 100 | } else { 101 | dispatch(deleteEventSuccess(res.body)); 102 | } 103 | }); 104 | }; 105 | } 106 | 107 | export function deleteEventRequest(event) { 108 | return { 109 | type: types.DELETE_EVENT_REQUEST, 110 | event 111 | }; 112 | } 113 | 114 | export function deleteEventSuccess(event) { 115 | return { 116 | type: types.DELETE_EVENT_SUCCESS, 117 | event 118 | }; 119 | } 120 | 121 | export function deleteEventFailure(error, event) { 122 | return { 123 | type: types.DELETE_EVENT_FAILURE, 124 | error, 125 | event 126 | }; 127 | } 128 | 129 | export function editEvent(event) { 130 | return dispatch => { 131 | dispatch(editEventRequest(event)); 132 | 133 | return request 134 | .post(eventsUrl + '/' + event.id) 135 | .send(event) 136 | .set('Accept', 'application/json') 137 | .end((err, res) => { 138 | if (err) { 139 | dispatch(editEventFailure(err, event)); 140 | } else { 141 | dispatch(editEventSuccess(res.body)); 142 | } 143 | }); 144 | }; 145 | } 146 | 147 | export function editEventRequest(event) { 148 | return { 149 | type: types.EDIT_EVENT_REQUEST, 150 | event 151 | }; 152 | } 153 | 154 | export function editEventSuccess(event) { 155 | return { 156 | type: types.EDIT_EVENT_SUCCESS, 157 | event 158 | }; 159 | } 160 | 161 | export function editEventFailure(error, event) { 162 | return { 163 | type: types.EDIT_EVENT_FAILURE, 164 | error, 165 | event 166 | }; 167 | } -------------------------------------------------------------------------------- /universal/components/AsyncBar.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes, Component } from 'react'; 2 | 3 | export default class AsyncBar extends Component { 4 | static propTypes = { 5 | isWorking: PropTypes.bool, 6 | error: PropTypes.string 7 | }; 8 | 9 | render() { 10 | let spinner = (this.props.isWorking) ? this.renderSpinner() : null; 11 | let error = (this.props.error) ? this.renderError() : null; 12 | 13 | return ( 14 |
15 | {spinner} 16 | {error} 17 |
18 | ); 19 | } 20 | 21 | renderSpinner() { 22 | return ( 23 |
24 | Loading… 25 |
26 | ); 27 | } 28 | 29 | renderError() { 30 | return ( 31 |

32 | {this.props.error} 33 |

34 | ); 35 | } 36 | } -------------------------------------------------------------------------------- /universal/components/EventInput.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { VALUE_CLASSES } from '../constants/ActionTypes.js'; 3 | 4 | export default class EventInput extends Component { 5 | static propTypes = { 6 | onSubmit: PropTypes.func.isRequired, 7 | userId: PropTypes.string.isRequired, 8 | textLabel: PropTypes.string, 9 | valueLabel: PropTypes.string, 10 | editing: PropTypes.bool 11 | }; 12 | 13 | constructor(props, context) { 14 | super(props, context); 15 | this.state = { 16 | errors: [], 17 | text: this.props.text || '', 18 | value: this.props.value || 50 19 | }; 20 | } 21 | 22 | handleSubmit(e) { 23 | let errors; 24 | e.preventDefault(); 25 | 26 | if (this.state.text.length === 0) { 27 | errors = ['You have not said what happened!']; 28 | } 29 | 30 | if (this.state.value < 1 || this.state.value > 100) { 31 | errors = [...errors, 'You have somewhere set an invalid value!']; 32 | } 33 | 34 | if (errors && errors.length > 0) { 35 | this.setState({errors: errors}); 36 | } else { 37 | this.props.onSubmit({text: this.state.text, value: this.state.value, userId: this.props.userId}); 38 | this.setState({text: '', value: 50}); 39 | } 40 | } 41 | 42 | handleTextChange(e) { 43 | this.setState({ text: e.target.value }); 44 | } 45 | 46 | handleValueChange(e) { 47 | this.setState({ value: parseInt(e.target.value, 10) }); 48 | } 49 | 50 | render() { 51 | let self = this; 52 | let saveText = (this.props.editing) ? 'Save' : 'Add'; 53 | let className = Object.keys(VALUE_CLASSES).reduce((current, key) => { 54 | if (!current && self.state.value <= key) { 55 | return VALUE_CLASSES[key]; 56 | } else { 57 | return current; 58 | } 59 | }, null); 60 | 61 | return ( 62 |
63 |
64 | 65 | 66 | 67 | {this.state.value} 68 | 69 |
70 |
71 | ); 72 | } 73 | } -------------------------------------------------------------------------------- /universal/components/EventItem.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes, Component} from 'react'; 2 | import distanceInWords from 'date-fns/distance_in_words'; 3 | import EventInput from './EventInput'; 4 | 5 | export default class EventItem extends Component { 6 | static propTypes = { 7 | id: PropTypes.any.isRequired, 8 | row: PropTypes.number.isRequired, 9 | event: PropTypes.object.isRequired, 10 | editable: PropTypes.bool, 11 | editEvent: PropTypes.func, 12 | deleteEvent: PropTypes.func 13 | }; 14 | 15 | constructor(props, context){ 16 | super(props, context); 17 | this.state = { 18 | editing: false 19 | }; 20 | } 21 | 22 | handleClick() { 23 | if (this.props.editable) { 24 | this.setState({ editing: true }); 25 | } 26 | } 27 | 28 | handleSave(event) { 29 | if (event.text.length === 0) { 30 | this.props.deleteEvent(event); 31 | } else { 32 | this.props.editEvent(event); 33 | } 34 | this.setState({ editing: false }); 35 | } 36 | 37 | render() { 38 | const { row, id, event, editEvent, deleteEvent } = this.props; 39 | 40 | let element, className = (row % 2 === 0) ? 'even' : 'odd'; 41 | let modified = (event.updated) ? event.updated : event.created; 42 | 43 | if (this.state.editing) { 44 | element = ( 45 | this.handleSave(Object.assign({}, event, { id: id })) } /> 51 | ); 52 | } else { 53 | let del = (this.props.editable) ? 54 |