├── .gitignore ├── .babelrc ├── entrypoint.js ├── feedback.gif ├── flux-infra ├── dispatcher │ └── AppDispatcher.js ├── actions │ ├── LogActions.js │ └── TodoActions.js ├── constants │ └── TodoConstants.js └── stores │ ├── LogStore.js │ └── TodoStore.js ├── todomvc-common ├── bg.png └── base.css ├── services ├── todos.json └── TodoStorageService.js ├── .eslintrc ├── css └── app.css ├── views └── todo.ejs ├── components ├── AppContainer.react.js ├── Header.react.js ├── MainSection.react.js ├── Logs.react.js ├── Footer.react.js ├── TodoApp.react.js ├── TodoTextInput.react.js └── TodoItem.react.js ├── app.js ├── gulpfile.js ├── routes └── routes.js ├── LICENSE.md ├── package.json ├── api └── socketApi.js ├── README.md └── server.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"] 3 | } -------------------------------------------------------------------------------- /entrypoint.js: -------------------------------------------------------------------------------- 1 | require("babel-register"); 2 | require("./server"); 3 | -------------------------------------------------------------------------------- /feedback.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pierreavizou/universal-routed-flux-demo/HEAD/feedback.gif -------------------------------------------------------------------------------- /flux-infra/dispatcher/AppDispatcher.js: -------------------------------------------------------------------------------- 1 | import {Dispatcher} from 'flux'; 2 | 3 | export default new Dispatcher(); 4 | -------------------------------------------------------------------------------- /todomvc-common/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pierreavizou/universal-routed-flux-demo/HEAD/todomvc-common/bg.png -------------------------------------------------------------------------------- /services/todos.json: -------------------------------------------------------------------------------- 1 | { 2 | "igsf8ixk": { 3 | "id": "igsf8ixk", 4 | "complete": false, 5 | "text": "Hello from Server" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /flux-infra/actions/LogActions.js: -------------------------------------------------------------------------------- 1 | import AppDispatcher from '../dispatcher/AppDispatcher'; 2 | 3 | var LogActions = { 4 | clearLogs: function(){ 5 | AppDispatcher.dispatch({ 6 | actionType: 'LOG_DELETE' 7 | }); 8 | } 9 | }; 10 | 11 | module.exports = LogActions; 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "react" 4 | ], 5 | "parserOptions": { 6 | "ecmaVersion": 6, 7 | "sourceType": "module", 8 | "ecmaFeatures": { 9 | "modules": true, 10 | "jsx": true 11 | }, 12 | }, 13 | "env": { 14 | "browser": true, 15 | "node": true, 16 | "es6": true 17 | }, 18 | "extends": "eslint:recommended", 19 | "rules":{ 20 | "no-console":0, 21 | "react/jsx-uses-react": 1, 22 | "react/jsx-uses-vars": 1, 23 | "semi": [2, "always"] 24 | 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /flux-infra/constants/TodoConstants.js: -------------------------------------------------------------------------------- 1 | var keyMirror = require('keymirror'); 2 | 3 | module.exports = keyMirror({ 4 | TODO_INIT_CREATE: null, 5 | TODO_CREATE: null, 6 | TODO_COMPLETE: null, 7 | TODO_DESTROY: null, 8 | TODO_DESTROY_COMPLETED: null, 9 | TODO_TOGGLE_COMPLETE_ALL: null, 10 | TODO_UNDO_COMPLETE: null, 11 | TODO_UPDATE_TEXT: null, 12 | TODO_SERVER_SUCCESS: null, 13 | TODO_DELETE_SERVER_SUCCESS: null, 14 | READ_SUCCESS: null, 15 | INIT_SOCKET: null, 16 | SOCKET_CONNECTED: null, 17 | SERVER_ERROR: null 18 | }); 19 | -------------------------------------------------------------------------------- /css/app.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014-2015, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | * 9 | * base.css overrides 10 | */ 11 | 12 | /** 13 | * We are not changing from display:none, but rather re-rendering instead. 14 | * Therefore this needs to be displayed normally by default. 15 | */ 16 | #todo-list li .edit { 17 | display: inline; 18 | } -------------------------------------------------------------------------------- /views/todo.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Universal Flux Todo Application with Reactjs and React Router. 5 | 6 | 7 | 8 | 9 | 13 | 14 |
<%-todoapp%>
15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /components/AppContainer.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Link} from 'react-router'; 3 | 4 | 5 | export default class AppContainer extends React.Component{ 6 | constructor(props) { 7 | super(props); 8 | } 9 | 10 | render(){ 11 | var nav =
12 |

13 | Todo list without logs
14 | Todo list with logs 15 |

16 |
; 17 | 18 | return ( 19 |
20 | {this.props.children || nav} 21 |
22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | 3 | import TodoActions from './flux-infra/actions/TodoActions'; 4 | import routes from './routes/routes'; 5 | 6 | // This action dispatches the READ_SUCCESS action to the store along with the data. 7 | // It then executes the callback passed as the second parameter. 8 | // Here, it renders the app and mounts it to the appropriate component. 9 | // Dispatching this action both client- and server-side is necessary as it 10 | // ensures that both will render the same markup because they have the same data. 11 | TodoActions.sendTodos(window.App, function(){ 12 | ReactDOM.render( 13 | routes, 14 | document.getElementById('todoappcontainer') 15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'), 2 | eslint = require('gulp-eslint'); 3 | 4 | gulp.task('lint', function () { 5 | // ESLint ignores files with "node_modules" paths. 6 | // So, it's best to have gulp ignore the directory as well. 7 | // Also, Be sure to return the stream from the task; 8 | // Otherwise, the task may end before the stream has finished. 9 | return gulp.src(['**/*.js','!node_modules/**']) 10 | // eslint() attaches the lint output to the "eslint" property 11 | // of the file object so it can be used by other modules. 12 | .pipe(eslint()) 13 | // eslint.format() outputs the lint results to the console. 14 | // Alternatively use eslint.formatEach() (see Docs). 15 | .pipe(eslint.format()) 16 | // To have the process exit with an error code (1) on 17 | // lint error, return the stream and pipe to failAfterError last. 18 | .pipe(eslint.failAfterError()); 19 | }); 20 | -------------------------------------------------------------------------------- /routes/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router, Route} from 'react-router/umd/ReactRouter'; 3 | import createBrowserHistory from 'history/lib/createBrowserHistory'; 4 | import createMemoryHistory from 'history/lib/createMemoryHistory'; 5 | 6 | var history; 7 | if (typeof(window) !== 'undefined'){ 8 | history = createBrowserHistory(); 9 | } 10 | else { 11 | history = createMemoryHistory(); //This kind of history is needed for server-side rendering. 12 | } 13 | 14 | import TodoApp from '../components/TodoApp.react'; 15 | import LogDiv from '../components/Logs.react'; 16 | import AppContainer from '../components/AppContainer.react'; 17 | 18 | var routes = ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | 28 | export default routes; 29 | -------------------------------------------------------------------------------- /components/Header.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TodoActions from '../flux-infra/actions/TodoActions'; 3 | import TodoTextInput from './TodoTextInput.react'; 4 | 5 | export default class Header extends React.Component{ 6 | 7 | constructor(props){ 8 | super(props); 9 | } 10 | 11 | /** 12 | * @return {object} 13 | */ 14 | render () { 15 | return ( 16 | 25 | ); 26 | } 27 | 28 | /** 29 | * Event handler called within TodoTextInput. 30 | * Defining this here allows TodoTextInput to be used in multiple places 31 | * in different ways. 32 | * @param {string} text 33 | */ 34 | _onSave (text) { 35 | if (text.trim()){ 36 | TodoActions.create(text); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Pierre Avizou 4 | 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /flux-infra/stores/LogStore.js: -------------------------------------------------------------------------------- 1 | import AppDispatcher from '../dispatcher/AppDispatcher'; 2 | import {EventEmitter} from 'events'; 3 | 4 | var CHANGE_EVENT = 'change'; 5 | 6 | var _logs = []; 7 | 8 | function addLog(text, item) { 9 | _logs.push(text + " : " + item); 10 | //console.log("log ajouté."); 11 | } 12 | 13 | function clearLogs(){ 14 | console.log('Clearing logs'); 15 | _logs = []; 16 | } 17 | 18 | class LogStore extends EventEmitter{ 19 | constructor(){ 20 | super(); 21 | } 22 | 23 | getState(){ 24 | return { 25 | logs: _logs 26 | }; 27 | } 28 | 29 | emitChange(){ 30 | this.emit(CHANGE_EVENT); 31 | } 32 | 33 | addChangeListener(callback){ 34 | this.on(CHANGE_EVENT, callback); 35 | } 36 | 37 | removeChangeListener(callback){ 38 | this.removeListener(CHANGE_EVENT, callback); 39 | } 40 | } 41 | 42 | var logStoreObj = new LogStore(); 43 | 44 | AppDispatcher.register(function(payload){ 45 | if (payload.actionType === 'LOG_DELETE'){ 46 | clearLogs(); 47 | logStoreObj.emitChange(); 48 | return; 49 | } 50 | addLog(payload.actionType, payload.text || payload.id); 51 | logStoreObj.emitChange(); 52 | //addLog(payload.actionType); 53 | }); 54 | 55 | export default logStoreObj; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "universal-routed-flux-demo", 3 | "version": "0.1.0", 4 | "description": "Get started building universal flux applications, with modern and exciting technologies such as Reactjs, React Router and es6.", 5 | "main": "entrypoint.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node entrypoint.js" 9 | }, 10 | "author": "Pierre Avizou", 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/pierreavizou/universal-routed-flux-demo.git" 15 | }, 16 | "dependencies": { 17 | "babel-preset-es2015": "^6.3.13", 18 | "babel-preset-react": "^6.3.13", 19 | "babel-register": "^6.3.13", 20 | "babelify": "^7.2.0", 21 | "browserify-middleware": "^7.0.0", 22 | "classnames": "^2.1.5", 23 | "ejs": "^2.3.3", 24 | "express": "^4.13.3", 25 | "flux": "^2.1.1", 26 | "history": "^1.17.0", 27 | "keymirror": "^0.1.1", 28 | "react": "^0.14.3", 29 | "react-dom": "^0.14.3", 30 | "react-router": "^1.0.3", 31 | "socket.io": "^1.3.7", 32 | "socket.io-client": "^1.3.7" 33 | }, 34 | "devDependencies": { 35 | "eslint": "^1.10.1", 36 | "eslint-plugin-react": "^3.10.0", 37 | "gulp": "^3.9.0", 38 | "gulp-eslint": "^1.1.1", 39 | "react-addons-perf": "^0.14.2" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /services/TodoStorageService.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | //simulate server delay 3 | const RESPONSE_TIME = 300; 4 | 5 | class TodoStorageService { 6 | constructor(storePath = __dirname + '/todos.json') { 7 | console.log('storage service initialised'); 8 | this._todos = {}; 9 | this.storePath = storePath; 10 | } 11 | write(todo, cb){ 12 | this._todos[todo.id] = todo; 13 | setTimeout(() => { 14 | fs.writeFile(this.storePath, JSON.stringify(this._todos, null, 4), function(err){ 15 | cb(err); 16 | }); 17 | }, RESPONSE_TIME); 18 | } 19 | 20 | read(cb){ 21 | fs.readFile(this.storePath, (err, data) => { 22 | setTimeout(() => { 23 | if (err !== null){ 24 | cb(err, null); 25 | return; 26 | } 27 | this._todos = JSON.parse(data); 28 | cb(null, this._todos); 29 | }, RESPONSE_TIME); 30 | }); 31 | } 32 | 33 | delete(id, cb){ 34 | delete this._todos[id]; 35 | setTimeout(() => { 36 | fs.writeFile(this.storePath, JSON.stringify(this._todos, null, 4), function(err){ 37 | cb(err); 38 | }); 39 | }, RESPONSE_TIME); 40 | }} 41 | 42 | export default new TodoStorageService(); 43 | -------------------------------------------------------------------------------- /components/MainSection.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TodoActions from '../flux-infra/actions/TodoActions'; 3 | import TodoItem from './TodoItem.react'; 4 | var ReactPropTypes = React.PropTypes; 5 | 6 | export default class MainSection extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | } 10 | 11 | render(){ 12 | var allTodos = this.props.allTodos; 13 | // This section should be hidden by default 14 | // and shown when there are todos. 15 | if (Object.keys(allTodos).length < 1) { 16 | return null; 17 | } 18 | 19 | var todos = []; 20 | 21 | for (var key in allTodos) { 22 | todos.push(); 23 | } 24 | 25 | return ( 26 |
27 | 33 | 34 | 35 |
36 | ); 37 | } 38 | 39 | _onToggleCompleteAll(){ 40 | TodoActions.toggleCompleteAll(); 41 | } 42 | } 43 | 44 | MainSection.PropTypes = { 45 | allTodos: ReactPropTypes.object.isRequired, 46 | areAllComplete: ReactPropTypes.bool.isRequired 47 | }; 48 | -------------------------------------------------------------------------------- /api/socketApi.js: -------------------------------------------------------------------------------- 1 | var initApi = function(server, todoStorageService){ 2 | 3 | var io = require('socket.io').listen(server, {transports: ['websocket']}); 4 | 5 | io.sockets.on('connection', function(socket){ 6 | 7 | console.log('A new client connected to the server'); 8 | socket.emit('connected'); 9 | 10 | socket.on('todoupdate', function(todo){ 11 | console.log('new todo item received: ' + todo); 12 | var jTodo = JSON.parse(todo); 13 | //jTodo.pending = false; 14 | delete jTodo.pending; //we do not need to store the 'pending' attribute so we can safely delete it. 15 | todoStorageService.write(jTodo, function(err){ 16 | if(err !== null){ 17 | console.log('API error: ' + err); 18 | socket.emit('servererror', JSON.stringify(err)); 19 | return; 20 | } 21 | socket.emit('todoreceived', jTodo.id); 22 | }); 23 | }); 24 | 25 | socket.on('tododelete', function(todo){ 26 | var jTodo = JSON.parse(todo); 27 | todoStorageService.delete(jTodo.id, function(err){ 28 | if(err !== null){ 29 | console.log('Error: ' + err); 30 | socket.emit('servererror', JSON.stringify(err)); 31 | return; 32 | } 33 | socket.emit('tododeleted', jTodo.id); 34 | }); 35 | }); 36 | }); 37 | }; 38 | 39 | export default initApi; 40 | -------------------------------------------------------------------------------- /components/Logs.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import LogStore from '../flux-infra/stores/LogStore'; 3 | import LogActions from '../flux-infra/actions/LogActions'; 4 | 5 | function getLogState(){ 6 | return LogStore.getState(); 7 | } 8 | 9 | export default class LogDiv extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | // Needed to fix markup inconsistency between client and server. 13 | //TODO: fix in a more elegant way. 14 | this.state = {logs: {}}; 15 | //needed as React does not autobind non-react method when used in es6. 16 | this._onChange = this._onChange.bind(this); 17 | } 18 | componentDidMount(){ 19 | console.log('logs mounted'); 20 | LogStore.addChangeListener(this._onChange); 21 | this.setState(getLogState()); 22 | } 23 | 24 | componentWillUnmount(){ 25 | LogStore.removeChangeListener(this._onChange); 26 | } 27 | 28 | _onChange(){ 29 | this.setState(getLogState()); 30 | } 31 | 32 | render(){ 33 | var logs = []; 34 | var clearButton; 35 | if(this.state.logs.length > 0){ 36 | clearButton = ; 37 | } 38 | 39 | for (var i = this.state.logs.length - 1; i >= 0; i--) { 40 | logs.push(
  • {this.state.logs[i]}
  • ); 41 | } 42 | return ( 43 |
    44 | 47 | {clearButton} 48 |
    49 | ); 50 | } 51 | 52 | _clearLogs(){ 53 | LogActions.clearLogs(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /components/Footer.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TodoActions from '../flux-infra/actions/TodoActions'; 3 | var ReactPropTypes = React.PropTypes; 4 | 5 | export default class Footer extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | } 9 | 10 | render(){ 11 | var allTodos = this.props.allTodos; 12 | var total = Object.keys(allTodos).length; 13 | 14 | if (total === 0) { 15 | return null; 16 | } 17 | 18 | var completed = 0; 19 | for (var key in allTodos) { 20 | if (allTodos[key].complete) { 21 | completed++; 22 | } 23 | } 24 | 25 | var itemsLeft = total - completed; 26 | var itemsLeftPhrase = itemsLeft === 1 ? ' item ' : ' items '; 27 | itemsLeftPhrase += 'left'; 28 | 29 | // Undefined and thus not rendered if no completed items are left. 30 | var clearCompletedButton; 31 | if (completed) { 32 | clearCompletedButton = 33 | ; 38 | } 39 | 40 | return ( 41 | 50 | ); 51 | } 52 | 53 | _onClearCompletedClick(){ 54 | TodoActions.destroyCompleted(); 55 | } 56 | } 57 | 58 | Footer.propTypes = {allTodos: ReactPropTypes.object.isRequired}; 59 | -------------------------------------------------------------------------------- /components/TodoApp.react.js: -------------------------------------------------------------------------------- 1 | import Footer from './Footer.react'; 2 | import Header from './Header.react'; 3 | import MainSection from './MainSection.react'; 4 | import React from 'react'; 5 | import TodoStore from '../flux-infra/stores/TodoStore'; 6 | import TodoActions from '../flux-infra/actions/TodoActions'; 7 | import {Link} from 'react-router'; 8 | 9 | /** 10 | * Retrieve the current TODO data from the TodoStore 11 | */ 12 | function getTodoState() { 13 | return { 14 | allTodos: TodoStore.getAll(), 15 | areAllComplete: TodoStore.areAllComplete() 16 | }; 17 | } 18 | 19 | export default class TodoApp extends React.Component{ 20 | constructor(props){ 21 | super(props); 22 | this.state = getTodoState(); 23 | this._onChange = this._onChange.bind(this); 24 | } 25 | 26 | componentDidMount(){ 27 | TodoStore.addChangeListener(this._onChange); 28 | TodoActions.initSocket(); // We initialize the websocket connection as soon as the TodoApp component has mounted 29 | } 30 | 31 | componentWillUnmount(){ 32 | TodoStore.removeChangeListener(this._onChange); 33 | } 34 | 35 | _onChange(){ 36 | this.setState(getTodoState()); 37 | } 38 | 39 | render(){ 40 | return ( 41 |
    42 |
    43 |
    44 | 48 |
    49 |
    50 |
    51 | {this.props.children || Display logs} 52 |
    53 |
    54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /components/TodoTextInput.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | var ReactPropTypes = React.PropTypes; 4 | var ENTER_KEY_CODE = 13; 5 | 6 | export default class TodoTextInput extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = {value : this.props.value} || ''; 10 | this._save = this._save.bind(this); 11 | this._onChange = this._onChange.bind(this); 12 | this._onKeyDown = this._onKeyDown.bind(this); 13 | } 14 | 15 | render(){ 16 | return ( 17 | 27 | ); 28 | } 29 | 30 | /** 31 | * Invokes the callback passed in as onSave, allowing this component to be 32 | * used in different ways. 33 | */ 34 | _save() { 35 | this.props.onSave(this.state.value); 36 | this.setState({ 37 | value: '' 38 | }); 39 | } 40 | 41 | /** 42 | * @param {object} event 43 | */ 44 | _onChange(/*object*/ event) { 45 | this.setState({ 46 | value: event.target.value 47 | }); 48 | } 49 | 50 | /** 51 | * @param {object} event 52 | */ 53 | _onKeyDown(event) { 54 | if (event.keyCode === ENTER_KEY_CODE) { 55 | this._save(); 56 | } 57 | } 58 | } 59 | 60 | TodoTextInput.propTypes = { 61 | className: ReactPropTypes.string, 62 | id: ReactPropTypes.string, 63 | placeholder: ReactPropTypes.string, 64 | onSave: ReactPropTypes.func.isRequired, 65 | value: ReactPropTypes.string 66 | }; 67 | 68 | TodoTextInput.defaultProps = {saveOnBlur: true}; 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Universal Flux example with React and React-Router 2 | 3 | **UPDATE 2** : *On the `immutable` branch you will find the app's TodoStore and LogStore implemented with Facebook's [Immutable](https://github.com/facebook/immutable-js) library. The implementation is inspired by [this one](https://github.com/MandarinConLaBarba/flux-immutable-todomvc).* 4 | 5 | **UPDATE** : *On the `flux-utils` branch you will find the flux architecture implemented with Flux's bundled `flux/utils`.* 6 | 7 | The code in this repo is intended for people who want to get started building universal flux applications, with modern and exciting technologies such as [React](https://facebook.github.io/react/), [React Router](https://github.com/rackt/react-router/) and [es6](https://github.com/ericdouglas/ES6-Learning). 8 | 9 | It consists of an example Todo Application built with flux and inspired by the [Facebook Flux tutorial](https://facebook.github.io/flux/docs/todo-list.html). 10 | 11 | The app has been rewritten to be fully universal (or ~~isomorphic~~), use es6, React Router, have a log component and provide asynchronous server communication. 12 | 13 | It combines the following notions in a full-featured example: 14 | 15 | * es6 16 | * Using React Router with server-side rendering 17 | * Flux architecture for universal applications 18 | * Multi-store application 19 | * Asynchronous server communication with visual feedback 20 | 21 | ![Visual feedback](https://raw.githubusercontent.com/pierreavizou/universal-routed-flux-demo/master/feedback.gif) 22 | 23 | ## Purpose 24 | 25 | This example app is here in the hope that it will help people getting started building great apps that use these recent technologies. 26 | 27 | After struggling to find guides and tutorials that combined everything I wanted to build an app, I decided to write and share an example of my own. 28 | 29 | ## Run the app 30 | 31 | The first thing you should do is clone the repo. 32 | Then, `cd`into the project folder and run `npm install`. 33 | This will download all the dependencies. 34 | 35 | Finally, all you have to do is run `node entrypoint.js` or `npm start`. 36 | 37 | ## Notes 38 | 39 | This app purposely uses no flux framework. The main reason is that frameworks usually hide many stuff from the user, which prevents from understanding properly how things work. 40 | 41 | Plus, the Flux architecture is reasonably clean, simple and elegant by itself, so using a framework is often not necessary, especially in example code. 42 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom/server'; 4 | import browserify from 'browserify-middleware'; 5 | import http from 'http'; 6 | import TodoActions from './flux-infra/actions/TodoActions'; 7 | import TodoStorageService from './services/TodoStorageService'; 8 | import {match, RoutingContext} from 'react-router'; 9 | import routes from './routes/routes'; 10 | import initApi from './api/socketApi'; 11 | 12 | var app = express(); 13 | const HOST = process.env.HOST || 'http://localhost:3000'; 14 | 15 | //browserify.settings.mode = 'production'; //uncomment to disable source watching and enable minification 16 | browserify.settings({transform: ['babelify']}); 17 | 18 | var shared = ['react', 'react-dom', 'react-router/umd/ReactRouter', 'history']; 19 | 20 | // When a client requests the paths below, we serve a browserified and babelified version of the files. 21 | app.use('/build/shared.js', browserify(shared)); 22 | app.use('/build/bundle.js', browserify('./app.js', { 23 | external: shared 24 | })); 25 | 26 | app.use(express.static(__dirname)); 27 | 28 | app.get('*', function(req, res){ 29 | 30 | match({ routes, location: req.url }, (error, redirectLocation, renderProps) => { 31 | if (error) { 32 | res.status(500).send(error.message); 33 | } 34 | else if (redirectLocation) { 35 | res.redirect(302, redirectLocation.pathname + redirectLocation.search); 36 | } 37 | else if (renderProps) { 38 | 39 | TodoStorageService.read(function(err, data){ 40 | if(err !== null){ 41 | console.log(err); 42 | res.send('Erreur : ' + err); 43 | res.end(); 44 | return; 45 | } 46 | // This action dispatches the READ_SUCCESS action to the store 47 | // along with the data. It then executes the callback passed as 48 | // the second parameter. Here, it renders the app and sends the 49 | // HTML to the template. 50 | TodoActions.sendTodos(data, function(){ 51 | var todoAppHtml = ReactDOM.renderToString(); 52 | res.render('todo.ejs', { 53 | todoapp: todoAppHtml, 54 | todos: JSON.stringify(data), 55 | host: HOST 56 | }); 57 | }); 58 | }); 59 | } 60 | else { 61 | res.status(404).send('Not found'); 62 | } 63 | }); 64 | }); 65 | 66 | var server = http.createServer(app); 67 | server.listen(3000, function() { 68 | console.log('Listening on port 3000...'); 69 | initApi(server, TodoStorageService); 70 | }); 71 | -------------------------------------------------------------------------------- /components/TodoItem.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TodoActions from '../flux-infra/actions/TodoActions'; 3 | import TodoTextInput from './TodoTextInput.react'; 4 | import classNames from 'classnames'; 5 | var ReactPropTypes = React.PropTypes; 6 | 7 | export default class TodoItem extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | this.state = { 11 | isEditing: false 12 | }; 13 | this._onToggleComplete = this._onToggleComplete.bind(this); 14 | this._onSave = this._onSave.bind(this); 15 | this._onDoubleClick = this._onDoubleClick.bind(this); 16 | this._onDestroyClick = this._onDestroyClick.bind(this); 17 | } 18 | 19 | render() { 20 | var todo = this.props.todo; 21 | var input; 22 | if (this.state.isEditing) { 23 | input = 24 | ; 29 | } 30 | 31 | // List items should get the class 'editing' when editing 32 | // and 'completed' when marked as completed. 33 | // Note that 'completed' is a classification while 'complete' is a state. 34 | // This differentiation between classification and state becomes important 35 | // in the naming of view actions toggleComplete() vs. destroyCompleted(). 36 | return ( 37 |
  • 44 |
    45 | 51 | 54 |
    56 | {input} 57 |
  • 58 | ); 59 | } 60 | 61 | _onToggleComplete() { 62 | TodoActions.toggleComplete(this.props.todo); 63 | } 64 | 65 | _onDoubleClick() { 66 | this.setState({isEditing: true}); 67 | } 68 | 69 | /** 70 | * Event handler called within TodoTextInput. 71 | * Defining this here allows TodoTextInput to be used in multiple places 72 | * in different ways. 73 | * @param {string} text 74 | */ 75 | _onSave(text) { 76 | if (this.props.todo.text === text){ 77 | this.setState({isEditing: false}); 78 | return; 79 | } 80 | TodoActions.updateText(this.props.todo.id, text); 81 | this.setState({isEditing: false}); 82 | } 83 | 84 | _onDestroyClick() { 85 | TodoActions.destroy(this.props.todo.id); 86 | } 87 | } 88 | 89 | TodoItem.propTypes = {todo: ReactPropTypes.object.isRequired}; 90 | -------------------------------------------------------------------------------- /flux-infra/actions/TodoActions.js: -------------------------------------------------------------------------------- 1 | import AppDispatcher from '../dispatcher/AppDispatcher'; 2 | import TodoConstants from '../constants/TodoConstants'; 3 | 4 | const TodoActions = { 5 | 6 | /** 7 | * Action fired when the TodoApp component has successfully mounted. 8 | * Dispatches an action to the stores so that the TodoStore can init the connection. 9 | */ 10 | initSocket: function(){ 11 | AppDispatcher.dispatch({ 12 | actionType: TodoConstants.INIT_SOCKET 13 | }); 14 | }, 15 | 16 | /** 17 | * Action fired when the server has successfully retrieved the todos and passes them to the app 18 | * @param {object} todos The list of todos 19 | * @param {function} callback The callback to execute after dispatching the action 20 | */ 21 | 22 | sendTodos: function(todos, callback){ 23 | AppDispatcher.dispatch({ 24 | actionType: TodoConstants.READ_SUCCESS, 25 | todos:todos 26 | }); 27 | callback(); 28 | }, 29 | 30 | /** 31 | * @param {string} text 32 | */ 33 | create: function(text) { 34 | AppDispatcher.dispatch({ 35 | actionType: TodoConstants.TODO_CREATE, 36 | text: text 37 | }); 38 | }, 39 | 40 | /** 41 | * Action fired when a todo is successfully added to the list. 42 | * @param {string} text 43 | */ 44 | updateSuccess: function(id){ 45 | AppDispatcher.dispatch({ 46 | actionType: TodoConstants.TODO_SERVER_SUCCESS, 47 | id 48 | }); 49 | }, 50 | 51 | /** 52 | * Action fired when a todo is successfully deleted from the list. 53 | * @param {string} id The ID of the ToDo item 54 | */ 55 | 56 | deleteSuccess: function(id){ 57 | AppDispatcher.dispatch({ 58 | actionType: TodoConstants.TODO_DELETE_SERVER_SUCCESS, 59 | id 60 | }); 61 | }, 62 | 63 | /** 64 | * @param {string} id The ID of the ToDo item 65 | * @param {string} text 66 | */ 67 | updateText: function(id, text) { 68 | AppDispatcher.dispatch({ 69 | actionType: TodoConstants.TODO_UPDATE_TEXT, 70 | id: id, 71 | text: text 72 | }); 73 | }, 74 | 75 | /** 76 | * Toggle whether a single ToDo is complete 77 | * @param {object} todo 78 | */ 79 | toggleComplete: function(todo) { 80 | var id = todo.id; 81 | var actionType = todo.complete ? 82 | TodoConstants.TODO_UNDO_COMPLETE : 83 | TodoConstants.TODO_COMPLETE; 84 | 85 | AppDispatcher.dispatch({ 86 | actionType: actionType, 87 | id: id 88 | }); 89 | }, 90 | 91 | /** 92 | * Mark all ToDos as complete 93 | */ 94 | toggleCompleteAll: function() { 95 | AppDispatcher.dispatch({ 96 | actionType: TodoConstants.TODO_TOGGLE_COMPLETE_ALL 97 | }); 98 | }, 99 | 100 | /** 101 | * @param {string} id 102 | */ 103 | destroy: function(id) { 104 | AppDispatcher.dispatch({ 105 | actionType: TodoConstants.TODO_DESTROY, 106 | id: id 107 | }); 108 | }, 109 | 110 | /** 111 | * Delete all the completed ToDos 112 | */ 113 | destroyCompleted: function() { 114 | AppDispatcher.dispatch({ 115 | actionType: TodoConstants.TODO_DESTROY_COMPLETED 116 | }); 117 | }, 118 | 119 | /** 120 | * Action fired when the connection to the server is successful 121 | * @param {string} text The URL of the server we are connected to 122 | */ 123 | serverConnected: function(text){ 124 | AppDispatcher.dispatch({ 125 | actionType: TodoConstants.SOCKET_CONNECTED, 126 | text: text 127 | }); 128 | }, 129 | 130 | /** 131 | * Action fired when an error occurs on the server during data processing 132 | * @param {string} err String describing the error encountred 133 | */ 134 | serverError: function(err){ 135 | AppDispatcher.dispatch({ 136 | actionType: TodoConstants.SERVER_ERROR, 137 | text: err 138 | }); 139 | } 140 | }; 141 | 142 | export default TodoActions; 143 | -------------------------------------------------------------------------------- /flux-infra/stores/TodoStore.js: -------------------------------------------------------------------------------- 1 | /* jshint browser: true */ 2 | import AppDispatcher from '../dispatcher/AppDispatcher'; 3 | import {EventEmitter} from 'events'; 4 | import TodoConstants from '../constants/TodoConstants'; 5 | import TodoActions from '../actions/TodoActions'; 6 | 7 | //connect to server only if the code is executed client-side. 8 | if(typeof window !== 'undefined'){ 9 | var socket = require('socket.io-client')(window.HOST || 'localhost:3000', {transports: ['websocket']}); 10 | } 11 | 12 | var CHANGE_EVENT = 'change'; 13 | 14 | var _todos = {}; 15 | 16 | function receiveTodos(todos){ 17 | _todos = todos; 18 | } 19 | 20 | function create(text){ 21 | var id = (+new Date() + Math.floor(Math.random() * 999999)).toString(36); 22 | _todos[id] = { 23 | id: id, 24 | complete: false, 25 | text: text, 26 | pending: true 27 | }; 28 | socket.emit('todoupdate', JSON.stringify(_todos[id])); 29 | } 30 | 31 | function update(id, updates){ 32 | _todos[id].pending = true; 33 | _todos[id] = Object.assign({}, _todos[id], updates); 34 | socket.emit('todoupdate', JSON.stringify(_todos[id])); 35 | } 36 | 37 | function updateAll(updates){ 38 | for (var id in _todos){ 39 | update(id, updates); 40 | } 41 | } 42 | 43 | function updateStateOnServerSuccess(id){ 44 | console.log('server received todo: ' + id); 45 | _todos[id].pending = false; 46 | } 47 | 48 | function destroy(id){ 49 | _todos[id].pending = true; 50 | socket.emit('tododelete', JSON.stringify(_todos[id])); 51 | //delete _todos[id]; 52 | } 53 | 54 | function destroyWhenCompleteOnServer(id){ 55 | delete _todos[id]; 56 | } 57 | 58 | function destroyCompleted(){ 59 | for (var id in _todos){ 60 | if(_todos[id].complete){ 61 | _todos[id].pending = true; 62 | socket.emit('tododelete', JSON.stringify(_todos[id])); 63 | //destroyWhenCompleteOnServer(_todos[id]); 64 | } 65 | } 66 | } 67 | 68 | function initSocket(){ 69 | socket.on('todoreceived', function(id){ 70 | TodoActions.updateSuccess(id); 71 | }); 72 | socket.on('tododeleted', function(id){ 73 | TodoActions.deleteSuccess(id); 74 | }); 75 | socket.on('servererror', function(err){ 76 | TodoActions.serverError(err); 77 | }); 78 | socket.on('connected', function(){ 79 | TodoActions.serverConnected(window.HOST || 'localhost:3000'); 80 | }); 81 | } 82 | 83 | class TodoStore extends EventEmitter { 84 | constructor(){ 85 | super(); 86 | } 87 | 88 | areAllComplete(){ 89 | for (var id in _todos) { 90 | if (!_todos[id].complete) { 91 | return false; 92 | } 93 | } 94 | return true; 95 | } 96 | 97 | getAll(){ 98 | return _todos; 99 | } 100 | 101 | emitChange(){ 102 | this.emit(CHANGE_EVENT); 103 | } 104 | 105 | addChangeListener(callback){ 106 | this.on(CHANGE_EVENT, callback); 107 | } 108 | 109 | removeChangeListener(callback) { 110 | this.removeListener(CHANGE_EVENT, callback); 111 | } 112 | } 113 | 114 | var todoStoreObj = new TodoStore(); 115 | // Register callback to handle all updates 116 | todoStoreObj.dispatchToken = AppDispatcher.register(function(action) { 117 | var text; 118 | switch(action.actionType) { 119 | case TodoConstants.INIT_SOCKET: 120 | initSocket(); 121 | break; 122 | 123 | case TodoConstants.READ_SUCCESS: 124 | receiveTodos(action.todos); 125 | todoStoreObj.emitChange(); 126 | break; 127 | 128 | case TodoConstants.TODO_CREATE: 129 | text = action.text.trim(); 130 | if (text !== '') { 131 | create(text); 132 | todoStoreObj.emitChange(); 133 | } 134 | break; 135 | 136 | case TodoConstants.TODO_SERVER_SUCCESS: 137 | updateStateOnServerSuccess(action.id); 138 | todoStoreObj.emitChange(); 139 | break; 140 | 141 | case TodoConstants.TODO_TOGGLE_COMPLETE_ALL: 142 | if (todoStoreObj.areAllComplete()) { 143 | updateAll({complete: false}); 144 | } else { 145 | updateAll({complete: true}); 146 | } 147 | todoStoreObj.emitChange(); 148 | break; 149 | 150 | case TodoConstants.TODO_UNDO_COMPLETE: 151 | update(action.id, {complete: false}); 152 | todoStoreObj.emitChange(); 153 | break; 154 | 155 | case TodoConstants.TODO_COMPLETE: 156 | update(action.id, {complete: true}); 157 | todoStoreObj.emitChange(); 158 | break; 159 | 160 | case TodoConstants.TODO_UPDATE_TEXT: 161 | text = action.text.trim(); 162 | if (text !== '') { 163 | update(action.id, {text: text}); 164 | todoStoreObj.emitChange(); 165 | } 166 | break; 167 | 168 | case TodoConstants.TODO_DESTROY: 169 | destroy(action.id); 170 | todoStoreObj.emitChange(); 171 | break; 172 | 173 | case TodoConstants.TODO_DELETE_SERVER_SUCCESS: 174 | destroyWhenCompleteOnServer(action.id); 175 | todoStoreObj.emitChange(); 176 | break; 177 | 178 | case TodoConstants.TODO_DESTROY_COMPLETED: 179 | console.log('destroyCompleted'); 180 | destroyCompleted(); 181 | todoStoreObj.emitChange(); 182 | break; 183 | 184 | default: 185 | // no op 186 | } 187 | }); 188 | 189 | export default todoStoreObj; 190 | -------------------------------------------------------------------------------- /todomvc-common/base.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | button { 8 | margin: 0; 9 | padding: 0; 10 | border: 0; 11 | background: none; 12 | font-size: 100%; 13 | vertical-align: baseline; 14 | font-family: inherit; 15 | color: inherit; 16 | -webkit-appearance: none; 17 | -ms-appearance: none; 18 | -o-appearance: none; 19 | appearance: none; 20 | } 21 | 22 | body { 23 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 24 | line-height: 1.4em; 25 | background: #eaeaea url('bg.png'); 26 | color: #4d4d4d; 27 | width: 550px; 28 | margin: 0 auto; 29 | -webkit-font-smoothing: antialiased; 30 | -moz-font-smoothing: antialiased; 31 | -ms-font-smoothing: antialiased; 32 | -o-font-smoothing: antialiased; 33 | font-smoothing: antialiased; 34 | } 35 | 36 | button, 37 | input[type="checkbox"] { 38 | outline: none; 39 | } 40 | 41 | #todoapp { 42 | background: #fff; 43 | background: rgba(255, 255, 255, 0.9); 44 | margin: 130px 0 40px 0; 45 | border: 1px solid #ccc; 46 | position: relative; 47 | border-top-left-radius: 2px; 48 | border-top-right-radius: 2px; 49 | box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.2), 50 | 0 25px 50px 0 rgba(0, 0, 0, 0.15); 51 | } 52 | 53 | #todoapp:before { 54 | content: ''; 55 | border-left: 1px solid #f5d6d6; 56 | border-right: 1px solid #f5d6d6; 57 | width: 2px; 58 | position: absolute; 59 | top: 0; 60 | left: 40px; 61 | height: 100%; 62 | } 63 | 64 | #todoapp input::-webkit-input-placeholder { 65 | font-style: italic; 66 | } 67 | 68 | #todoapp input::-moz-placeholder { 69 | font-style: italic; 70 | color: #a9a9a9; 71 | } 72 | 73 | #todoapp h1 { 74 | position: absolute; 75 | top: -120px; 76 | width: 100%; 77 | font-size: 70px; 78 | font-weight: bold; 79 | text-align: center; 80 | color: #b3b3b3; 81 | color: rgba(255, 255, 255, 0.3); 82 | text-shadow: -1px -1px rgba(0, 0, 0, 0.2); 83 | -webkit-text-rendering: optimizeLegibility; 84 | -moz-text-rendering: optimizeLegibility; 85 | -ms-text-rendering: optimizeLegibility; 86 | -o-text-rendering: optimizeLegibility; 87 | text-rendering: optimizeLegibility; 88 | } 89 | 90 | #header { 91 | padding-top: 15px; 92 | border-radius: inherit; 93 | } 94 | 95 | #header:before { 96 | content: ''; 97 | position: absolute; 98 | top: 0; 99 | right: 0; 100 | left: 0; 101 | height: 15px; 102 | z-index: 2; 103 | border-bottom: 1px solid #6c615c; 104 | background: #8d7d77; 105 | background: -webkit-gradient(linear, left top, left bottom, from(rgba(132, 110, 100, 0.8)),to(rgba(101, 84, 76, 0.8))); 106 | background: -webkit-linear-gradient(top, rgba(132, 110, 100, 0.8), rgba(101, 84, 76, 0.8)); 107 | background: linear-gradient(top, rgba(132, 110, 100, 0.8), rgba(101, 84, 76, 0.8)); 108 | filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0,StartColorStr='#9d8b83', EndColorStr='#847670'); 109 | border-top-left-radius: 1px; 110 | border-top-right-radius: 1px; 111 | } 112 | 113 | #new-todo, 114 | .edit { 115 | position: relative; 116 | margin: 0; 117 | width: 100%; 118 | font-size: 24px; 119 | font-family: inherit; 120 | line-height: 1.4em; 121 | border: 0; 122 | outline: none; 123 | color: inherit; 124 | padding: 6px; 125 | border: 1px solid #999; 126 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 127 | -moz-box-sizing: border-box; 128 | -ms-box-sizing: border-box; 129 | -o-box-sizing: border-box; 130 | box-sizing: border-box; 131 | -webkit-font-smoothing: antialiased; 132 | -moz-font-smoothing: antialiased; 133 | -ms-font-smoothing: antialiased; 134 | -o-font-smoothing: antialiased; 135 | font-smoothing: antialiased; 136 | } 137 | 138 | #new-todo { 139 | padding: 16px 16px 16px 60px; 140 | border: none; 141 | background: rgba(0, 0, 0, 0.02); 142 | z-index: 2; 143 | box-shadow: none; 144 | } 145 | 146 | #main { 147 | position: relative; 148 | z-index: 2; 149 | border-top: 1px dotted #adadad; 150 | } 151 | 152 | label[for='toggle-all'] { 153 | display: none; 154 | } 155 | 156 | #toggle-all { 157 | position: absolute; 158 | top: -42px; 159 | left: -4px; 160 | width: 40px; 161 | text-align: center; 162 | /* Mobile Safari */ 163 | border: none; 164 | } 165 | 166 | #toggle-all:before { 167 | content: '»'; 168 | font-size: 28px; 169 | color: #d9d9d9; 170 | padding: 0 25px 7px; 171 | } 172 | 173 | #toggle-all:checked:before { 174 | color: #737373; 175 | } 176 | 177 | #todo-list { 178 | margin: 0; 179 | padding: 0; 180 | list-style: none; 181 | } 182 | 183 | #todo-list li { 184 | position: relative; 185 | font-size: 24px; 186 | border-bottom: 1px dotted #ccc; 187 | } 188 | 189 | #todo-list li:last-child { 190 | border-bottom: none; 191 | } 192 | 193 | #todo-list li.editing { 194 | border-bottom: none; 195 | padding: 0; 196 | } 197 | 198 | #todo-list li.editing .edit { 199 | display: block; 200 | width: 506px; 201 | padding: 13px 17px 12px 17px; 202 | margin: 0 0 0 43px; 203 | } 204 | 205 | #todo-list li.editing .view { 206 | display: none; 207 | } 208 | 209 | #todo-list li .toggle { 210 | text-align: center; 211 | width: 40px; 212 | /* auto, since non-WebKit browsers doesn't support input styling */ 213 | height: auto; 214 | position: absolute; 215 | top: 0; 216 | bottom: 0; 217 | margin: auto 0; 218 | /* Mobile Safari */ 219 | border: none; 220 | -webkit-appearance: none; 221 | -ms-appearance: none; 222 | -o-appearance: none; 223 | appearance: none; 224 | } 225 | 226 | #todo-list li .toggle:after { 227 | content: '✔'; 228 | /* 40 + a couple of pixels visual adjustment */ 229 | line-height: 43px; 230 | font-size: 20px; 231 | color: #d9d9d9; 232 | text-shadow: 0 -1px 0 #bfbfbf; 233 | } 234 | 235 | #todo-list li .toggle:checked:after { 236 | color: #85ada7; 237 | text-shadow: 0 1px 0 #669991; 238 | bottom: 1px; 239 | position: relative; 240 | } 241 | 242 | #todo-list li label { 243 | white-space: pre; 244 | word-break: break-word; 245 | padding: 15px 60px 15px 15px; 246 | margin-left: 45px; 247 | display: block; 248 | line-height: 1.2; 249 | -webkit-transition: color 0.4s; 250 | transition: color 0.4s; 251 | } 252 | 253 | #todo-list li.completed label { 254 | color: #a9a9a9; 255 | text-decoration: line-through; 256 | } 257 | 258 | #todo-list li .destroy { 259 | display: none; 260 | position: absolute; 261 | top: 0; 262 | right: 10px; 263 | bottom: 0; 264 | width: 40px; 265 | height: 40px; 266 | margin: auto 0; 267 | font-size: 22px; 268 | color: #a88a8a; 269 | -webkit-transition: all 0.2s; 270 | transition: all 0.2s; 271 | } 272 | 273 | #todo-list li .destroy:hover { 274 | text-shadow: 0 0 1px #000, 275 | 0 0 10px rgba(199, 107, 107, 0.8); 276 | -webkit-transform: scale(1.3); 277 | -ms-transform: scale(1.3); 278 | transform: scale(1.3); 279 | } 280 | 281 | #todo-list li .destroy:after { 282 | content: '✖'; 283 | } 284 | 285 | #todo-list li:hover .destroy { 286 | display: block; 287 | } 288 | 289 | #todo-list li .edit { 290 | display: none; 291 | } 292 | 293 | #todo-list li.editing:last-child { 294 | margin-bottom: -1px; 295 | } 296 | 297 | #footer { 298 | color: #777; 299 | padding: 0 15px; 300 | position: absolute; 301 | right: 0; 302 | bottom: -31px; 303 | left: 0; 304 | height: 20px; 305 | z-index: 1; 306 | text-align: center; 307 | } 308 | 309 | #footer:before { 310 | content: ''; 311 | position: absolute; 312 | right: 0; 313 | bottom: 31px; 314 | left: 0; 315 | height: 50px; 316 | z-index: -1; 317 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.3), 318 | 0 6px 0 -3px rgba(255, 255, 255, 0.8), 319 | 0 7px 1px -3px rgba(0, 0, 0, 0.3), 320 | 0 43px 0 -6px rgba(255, 255, 255, 0.8), 321 | 0 44px 2px -6px rgba(0, 0, 0, 0.2); 322 | } 323 | 324 | #todo-count { 325 | float: left; 326 | text-align: left; 327 | } 328 | 329 | #filters { 330 | margin: 0; 331 | padding: 0; 332 | list-style: none; 333 | position: absolute; 334 | right: 0; 335 | left: 0; 336 | } 337 | 338 | #filters li { 339 | display: inline; 340 | } 341 | 342 | #filters li a { 343 | color: #83756f; 344 | margin: 2px; 345 | text-decoration: none; 346 | } 347 | 348 | #filters li a.selected { 349 | font-weight: bold; 350 | } 351 | 352 | #clear-completed, #clearLogs { 353 | float: right; 354 | position: relative; 355 | line-height: 20px; 356 | text-decoration: none; 357 | background: rgba(0, 0, 0, 0.1); 358 | font-size: 11px; 359 | padding: 0 10px; 360 | border-radius: 3px; 361 | box-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.2); 362 | } 363 | 364 | #clear-completed:hover { 365 | background: rgba(0, 0, 0, 0.15); 366 | box-shadow: 0 -1px 0 0 rgba(0, 0, 0, 0.3); 367 | } 368 | 369 | #info { 370 | margin: 65px auto 0; 371 | color: #a6a6a6; 372 | font-size: 12px; 373 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7); 374 | text-align: center; 375 | } 376 | 377 | #info a { 378 | color: inherit; 379 | } 380 | 381 | /* 382 | Hack to remove background from Mobile Safari. 383 | Can't use it globally since it destroys checkboxes in Firefox and Opera 384 | */ 385 | 386 | @media screen and (-webkit-min-device-pixel-ratio:0) { 387 | #toggle-all, 388 | #todo-list li .toggle { 389 | background: none; 390 | } 391 | 392 | #todo-list li .toggle { 393 | height: 40px; 394 | } 395 | 396 | #toggle-all { 397 | top: -56px; 398 | left: -15px; 399 | width: 65px; 400 | height: 41px; 401 | -webkit-transform: rotate(90deg); 402 | -ms-transform: rotate(90deg); 403 | transform: rotate(90deg); 404 | -webkit-appearance: none; 405 | appearance: none; 406 | } 407 | } 408 | 409 | .hidden { 410 | display: none; 411 | } 412 | 413 | hr { 414 | margin: 20px 0; 415 | border: 0; 416 | border-top: 1px dashed #C5C5C5; 417 | border-bottom: 1px dashed #F7F7F7; 418 | } 419 | 420 | .learn a { 421 | font-weight: normal; 422 | text-decoration: none; 423 | color: #b83f45; 424 | } 425 | 426 | .learn a:hover { 427 | text-decoration: underline; 428 | color: #787e7e; 429 | } 430 | 431 | .learn h3, 432 | .learn h4, 433 | .learn h5 { 434 | margin: 10px 0; 435 | font-weight: 500; 436 | line-height: 1.2; 437 | color: #000; 438 | } 439 | 440 | .learn h3 { 441 | font-size: 24px; 442 | } 443 | 444 | .learn h4 { 445 | font-size: 18px; 446 | } 447 | 448 | .learn h5 { 449 | margin-bottom: 0; 450 | font-size: 14px; 451 | } 452 | 453 | .learn ul { 454 | padding: 0; 455 | margin: 0 0 30px 25px; 456 | } 457 | 458 | .learn li { 459 | line-height: 20px; 460 | } 461 | 462 | .learn p { 463 | font-size: 15px; 464 | font-weight: 300; 465 | line-height: 1.3; 466 | margin-top: 0; 467 | margin-bottom: 0; 468 | } 469 | 470 | .quote { 471 | border: none; 472 | margin: 20px 0 60px 0; 473 | } 474 | 475 | .quote p { 476 | font-style: italic; 477 | } 478 | 479 | .quote p:before { 480 | content: '“'; 481 | font-size: 50px; 482 | opacity: .15; 483 | position: absolute; 484 | top: -20px; 485 | left: 3px; 486 | } 487 | 488 | .quote p:after { 489 | content: '”'; 490 | font-size: 50px; 491 | opacity: .15; 492 | position: absolute; 493 | bottom: -42px; 494 | right: 3px; 495 | } 496 | 497 | .quote footer { 498 | position: absolute; 499 | bottom: -40px; 500 | right: 0; 501 | } 502 | 503 | .quote footer img { 504 | border-radius: 3px; 505 | } 506 | 507 | .quote footer a { 508 | margin-left: 5px; 509 | vertical-align: middle; 510 | } 511 | 512 | .speech-bubble { 513 | position: relative; 514 | padding: 10px; 515 | background: rgba(0, 0, 0, .04); 516 | border-radius: 5px; 517 | } 518 | 519 | .speech-bubble:after { 520 | content: ''; 521 | position: absolute; 522 | top: 100%; 523 | right: 30px; 524 | border: 13px solid transparent; 525 | border-top-color: rgba(0, 0, 0, .04); 526 | } 527 | 528 | .learn-bar > .learn { 529 | position: absolute; 530 | width: 272px; 531 | top: 8px; 532 | left: -300px; 533 | padding: 10px; 534 | border-radius: 5px; 535 | background-color: rgba(255, 255, 255, .6); 536 | -webkit-transition-property: left; 537 | transition-property: left; 538 | -webkit-transition-duration: 500ms; 539 | transition-duration: 500ms; 540 | } 541 | 542 | #todo-list li.completed.pending label, .pending { 543 | color:orange !important; 544 | } 545 | 546 | @media (min-width: 899px) { 547 | .learn-bar { 548 | width: auto; 549 | margin: 0 0 0 300px; 550 | } 551 | 552 | .learn-bar > .learn { 553 | left: 8px; 554 | } 555 | 556 | .learn-bar #todoapp { 557 | width: 550px; 558 | margin: 130px auto 40px auto; 559 | } 560 | } 561 | --------------------------------------------------------------------------------