├── .babelrc ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .jshintrc ├── README.md ├── gulp ├── config.js └── tasks │ ├── build.js │ ├── copy.js │ ├── default.js │ ├── stylus.js │ ├── watch.js │ └── webserver.js ├── gulpfile.babel.js ├── package.json ├── src ├── js │ ├── actions │ │ └── todoActions.js │ ├── app.js │ ├── components │ │ ├── Footer.js │ │ ├── Header.js │ │ ├── MainSection.js │ │ ├── TodoApp.js │ │ ├── TodoItem.js │ │ └── TodoTextInput.js │ ├── constants │ │ └── todo.js │ ├── dispatcher │ │ └── TodoDispatcher.js │ ├── stores │ │ └── todoStore.js │ └── utils │ │ └── todoApi.js ├── styl │ ├── app.styl │ └── base │ │ ├── _core.styl │ │ └── _reset.styl └── www │ ├── img │ └── bg.png │ └── index.html └── webpack.config.babel.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-class-properties" 4 | ], 5 | "presets": [ 6 | "es2015", 7 | "react" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '37 2 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esnext": true, 3 | "validthis": true, 4 | "asi": true 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TodoMVC with React + Flux 2 | 3 | ## Getting Started 4 | ``` 5 | $ npm install 6 | $ npm run start 7 | ``` 8 | 9 | ## References 10 | - 11 | - 12 | -------------------------------------------------------------------------------- /gulp/config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const dest = './build'; 4 | const src = './src'; 5 | const relativeSrcPath = path.relative('.', src); 6 | 7 | module.exports = { 8 | 9 | dest: dest, 10 | 11 | js: { 12 | src: `${src}/js/**`, 13 | dest: `${dest}/js`, 14 | uglify: false 15 | }, 16 | 17 | copy: { 18 | src: [ 19 | `${src}/www/**` 20 | ], 21 | dest: dest 22 | }, 23 | 24 | stylus: { 25 | src: [ 26 | `${src}/styl/**/!(_)*` 27 | ], 28 | dest: `${dest}/css/`, 29 | output: 'app.css', 30 | autoprefixer: { 31 | browsers: ['last 2 versions'] 32 | }, 33 | minify: false 34 | }, 35 | 36 | watch: { 37 | js: `${relativeSrcPath}/js/**`, 38 | styl: `${relativeSrcPath}/styl/**`, 39 | www: `${relativeSrcPath}/www/index.html` 40 | }, 41 | 42 | webserver: { 43 | host: 'localhost', 44 | port: 8000, 45 | livereload: true, 46 | fallback: dest, 47 | open: 'http://localhost:8000' 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /gulp/tasks/build.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | 3 | gulp.task('build', ['copy', 'stylus']); 4 | -------------------------------------------------------------------------------- /gulp/tasks/copy.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const config = require('../config').copy; 3 | 4 | gulp.task('copy', () => { 5 | gulp.src(config.src) 6 | .pipe(gulp.dest(config.dest)); 7 | }); 8 | -------------------------------------------------------------------------------- /gulp/tasks/default.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | 3 | gulp.task('default', ['build', 'watch', 'webserver']); 4 | -------------------------------------------------------------------------------- /gulp/tasks/stylus.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const gulpif = require('gulp-if'); 3 | const plumber = require('gulp-plumber'); 4 | const stylus = require('gulp-stylus'); 5 | const concat = require('gulp-concat'); 6 | const autoprefixer = require('gulp-autoprefixer'); 7 | const minify = require('gulp-minify-css'); 8 | const config = require('../config').stylus; 9 | 10 | gulp.task('stylus', () => { 11 | gulp.src(config.src) 12 | .pipe(plumber()) 13 | .pipe(stylus()) 14 | .pipe(concat(config.output)) 15 | .pipe(autoprefixer(config.autoprefixer)) 16 | .pipe(gulpif(config.minify, minify())) 17 | .pipe(gulp.dest(config.dest)); 18 | }); 19 | -------------------------------------------------------------------------------- /gulp/tasks/watch.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const watch = require('gulp-watch'); 3 | const config = require('../config').watch; 4 | 5 | gulp.task('watch', () => { 6 | // js 7 | watch(config.js, () => { 8 | gulp.start(['webpack']); 9 | }); 10 | 11 | // styl 12 | watch(config.styl, () => { 13 | gulp.start(['stylus']); 14 | }); 15 | 16 | // www 17 | watch(config.www, () => { 18 | gulp.start(['copy']); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /gulp/tasks/webserver.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const webserver = require('gulp-webserver'); 3 | const config = require('../config'); 4 | 5 | gulp.task('webserver', () => { 6 | gulp.src(config.dest) 7 | .pipe(webserver(config.webserver)); 8 | }); 9 | -------------------------------------------------------------------------------- /gulpfile.babel.js: -------------------------------------------------------------------------------- 1 | const requireDir = require('require-dir'); 2 | 3 | requireDir('./gulp/tasks', { recurse: true }); 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-flux-todomvc-example", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "src/js/app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "gulp build && webpack", 9 | "start": "npm run build && gulp watch webserver" 10 | }, 11 | "author": "sskyu", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "babel-cli": "^6.16.0", 15 | "babel-core": "^6.16.0", 16 | "babel-loader": "^6.2.5", 17 | "babel-plugin-transform-class-properties": "^6.16.0", 18 | "babel-preset-es2015": "^6.16.0", 19 | "babel-preset-react": "^6.16.0", 20 | "babel-register": "^6.16.3", 21 | "gulp": "~3.9.0", 22 | "gulp-autoprefixer": "^2.0.0", 23 | "gulp-concat": "^2.4.2", 24 | "gulp-if": "~1.2.5", 25 | "gulp-minify-css": "^0.3.11", 26 | "gulp-plumber": "^0.6.6", 27 | "gulp-stylus": "^1.3.4", 28 | "gulp-watch": "^3.0.0", 29 | "gulp-webserver": "^0.9.0", 30 | "require-dir": "~0.1.0", 31 | "webpack": "^1.13.2" 32 | }, 33 | "dependencies": { 34 | "babel-polyfill": "^6.16.0", 35 | "classnames": "^2.2.5", 36 | "events": "~1.0.2", 37 | "flux": "~3.0.0", 38 | "keymirror": "~0.1.1", 39 | "react": "^15.3.2", 40 | "react-dom": "^15.3.2" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/js/actions/todoActions.js: -------------------------------------------------------------------------------- 1 | import TodoDispatcher from '../dispatcher/TodoDispatcher'; 2 | import todoConstants from '../constants/todo'; 3 | import todoApi from '../utils/todoApi'; 4 | 5 | export default { 6 | // user actions 7 | create: (text) => { 8 | TodoDispatcher.dispatch({ 9 | actionType: todoConstants.CREATE, 10 | text: text 11 | }); 12 | 13 | todoApi.save(text); 14 | }, 15 | 16 | updateText: (id, text) => { 17 | TodoDispatcher.dispatch({ 18 | actionType: todoConstants.UPDATE_TEXT, 19 | id: id, 20 | text: text 21 | }); 22 | 23 | todoApi.update({ 24 | id: id, 25 | text: text 26 | }); 27 | }, 28 | 29 | toggleComplete: (todo) => { 30 | let id = todo.id; 31 | let actionType = todo.complete ? 32 | todoConstants.UNDO_COMPLETE : 33 | todoConstants.COMPLETE; 34 | 35 | TodoDispatcher.dispatch({ 36 | actionType: actionType, 37 | id: id 38 | }); 39 | 40 | todoApi.update({ 41 | id: todo.id, 42 | text: todo.text, 43 | complete: !todo.complete 44 | }); 45 | }, 46 | 47 | toggleCompleteAll: () => { 48 | TodoDispatcher.dispatch({ 49 | actionType: todoConstants.TOGGLE_COMPLETE_ALL 50 | }); 51 | 52 | todoApi.toggleComplete(); 53 | }, 54 | 55 | destroy: (id) => { 56 | TodoDispatcher.dispatch({ 57 | actionType: todoConstants.DESTROY, 58 | id: id 59 | }); 60 | 61 | todoApi.destroy(id); 62 | }, 63 | 64 | destroyCompleted: () => { 65 | TodoDispatcher.dispatch({ 66 | actionType: todoConstants.DESTROY_COMPLETED 67 | }); 68 | 69 | todoApi.destroyCompleted(); 70 | }, 71 | 72 | // api actions 73 | fetchTodos: () => { 74 | todoApi.fetch((todos) => { 75 | TodoDispatcher.dispatch({ 76 | actionType: todoConstants.FETCH_TODOS, 77 | todos: todos 78 | }); 79 | }); 80 | }, 81 | 82 | syncTodos: (todos) => { 83 | TodoDispatcher.dispatch({ 84 | actionType: todoConstants.SYNC_TODOS, 85 | todos: todos 86 | }); 87 | } 88 | } -------------------------------------------------------------------------------- /src/js/app.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import React from 'react'; 3 | import { render } from 'react-dom'; 4 | import TodoApp from './components/TodoApp'; 5 | 6 | render( 7 | , 8 | document.getElementById('todoapp') 9 | ); 10 | -------------------------------------------------------------------------------- /src/js/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import todoActions from '../actions/todoActions'; 3 | 4 | const PT = React.PropTypes; 5 | 6 | export default class Footer extends Component { 7 | 8 | static propTypes = { 9 | allTodos: PropTypes.object.isRequired 10 | }; 11 | 12 | constructor(...args) { 13 | super(...args); 14 | 15 | this.handleClick = this.handleClick.bind(this); 16 | } 17 | 18 | render() { 19 | let total = this._getTotal(); 20 | 21 | if (!total) { 22 | return null; 23 | } 24 | 25 | let completedNum = this._getCompletedNum(); 26 | let itemsLeft = total - completedNum; 27 | let itemsLeftPhrase = itemsLeft === 1 ? ' item ' : ' items '; 28 | itemsLeftPhrase += 'left'; 29 | 30 | let clearCompleteButton = this._createClearCompleteButton(completedNum); 31 | 32 | return ( 33 |
34 | 35 | 36 | {itemsLeft} 37 | 38 | {itemsLeftPhrase} 39 | 40 | {clearCompleteButton} 41 |
42 | ); 43 | } 44 | 45 | _getTotal() { 46 | return Object.keys(this.props.allTodos).length; 47 | } 48 | 49 | _getCompletedNum() { 50 | let completed = 0; 51 | let allTodos = this.props.allTodos; 52 | 53 | for (let key in allTodos) { 54 | if (allTodos[key].complete) { 55 | completed++; 56 | } 57 | } 58 | 59 | return completed; 60 | } 61 | 62 | _createClearCompleteButton(completed) { 63 | if (!completed) { 64 | return null; 65 | } 66 | 67 | return ( 68 | 74 | ); 75 | } 76 | 77 | handleClick() { 78 | todoActions.destroyCompleted(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/js/components/Header.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import todoActions from '../actions/todoActions'; 3 | import TodoTextInput from './TodoTextInput'; 4 | 5 | export default class Header extends Component { 6 | 7 | constructor(...args) { 8 | super(...args); 9 | 10 | this.handleSave = this.handleSave.bind(this); 11 | } 12 | 13 | render() { 14 | return ( 15 | 23 | ); 24 | } 25 | 26 | handleSave(text) { 27 | if (text.trim()) { 28 | todoActions.create(text); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/js/components/MainSection.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import TodoActions from '../actions/todoActions'; 3 | import TodoItem from './TodoItem'; 4 | 5 | export default class MainSection extends Component { 6 | 7 | static propTypes = { 8 | allTodos: PropTypes.object.isRequired, 9 | areAllComplete: PropTypes.bool.isRequired 10 | }; 11 | 12 | constructor(...args) { 13 | super(...args); 14 | 15 | this.handleChange = this.handleChange.bind(this); 16 | } 17 | 18 | render() { 19 | if (!this._hasTodo()) { 20 | return null; 21 | } 22 | 23 | let todos = this._getAllTodos(); 24 | 25 | return ( 26 |
27 | 33 | 34 |
    35 | {todos} 36 |
37 |
38 | ); 39 | } 40 | 41 | _hasTodo() { 42 | return Object.keys(this.props.allTodos).length > 0; 43 | } 44 | 45 | _getAllTodos() { 46 | let allTodos = this.props.allTodos; 47 | let todos = []; 48 | 49 | for (let key in allTodos) { 50 | todos.push(); 51 | } 52 | 53 | return todos; 54 | } 55 | 56 | handleChange() { 57 | TodoActions.toggleCompleteAll(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/js/components/TodoApp.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import todoStore from '../stores/todoStore'; 3 | import todoActions from '../actions/todoActions'; 4 | import Header from './Header'; 5 | import MainSection from './MainSection'; 6 | import Footer from './Footer'; 7 | 8 | function getTodoState() { 9 | return { 10 | allTodos: todoStore.getAll(), 11 | areAllComplete: todoStore.areAllComplete() 12 | } 13 | } 14 | 15 | export default class TodoApp extends React.Component { 16 | 17 | state = getTodoState(); 18 | 19 | componentDidMount() { 20 | todoStore.addChangeListener(this._onChange.bind(this)); 21 | 22 | todoActions.fetchTodos(); 23 | } 24 | 25 | componentWillUnmount() { 26 | todoStore.removeChangeListener(this._onChange.bind(this)); 27 | } 28 | 29 | render() { 30 | return ( 31 |
32 |
33 | 37 |
38 |
39 | ); 40 | } 41 | 42 | _onChange() { 43 | this.setState(getTodoState()); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/js/components/TodoItem.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import classNames from 'classnames'; 3 | import todoActions from '../actions/todoActions'; 4 | import TodoTextInput from './TodoTextInput'; 5 | 6 | export default class TodoItem extends Component { 7 | 8 | static propTypes = { 9 | todo: PropTypes.object.isRequired 10 | }; 11 | 12 | state = { isEditing: false }; 13 | 14 | constructor(...args) { 15 | super(...args); 16 | 17 | this.handleSave = this.handleSave.bind(this); 18 | this.handleChangeCheckbox = this.handleChangeCheckbox.bind(this); 19 | this.handleDoubleClick = this.handleDoubleClick.bind(this); 20 | this.handleDestroyClick = this.handleDestroyClick.bind(this); 21 | } 22 | 23 | render() { 24 | let todo = this.props.todo; 25 | let input; 26 | 27 | if (this.state.isEditing) { 28 | input = 29 | 34 | } 35 | 36 | return ( 37 |
  • 41 |
    42 | 48 | 51 |
    56 | {input} 57 |
  • 58 | ); 59 | } 60 | 61 | _getListClassName(todo) { 62 | return classNames({ 63 | 'completed': todo.complete, 64 | 'editing': this.state.isEditing 65 | }); 66 | } 67 | 68 | handleChangeCheckbox() { 69 | todoActions.toggleComplete(this.props.todo); 70 | } 71 | 72 | handleDoubleClick() { 73 | this.setState({ isEditing: true }); 74 | } 75 | 76 | handleSave(text) { 77 | todoActions.updateText(this.props.todo.id, text); 78 | this.setState({ isEditing: false }); 79 | } 80 | 81 | handleDestroyClick() { 82 | todoActions.destroy(this.props.todo.id); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/js/components/TodoTextInput.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | const ENTER_KEY_CODE = 13; 4 | 5 | export default class TodoTextInput extends Component { 6 | 7 | static propTypes = { 8 | className : PropTypes.string, 9 | id : PropTypes.string, 10 | placeholder : PropTypes.string, 11 | onSave : PropTypes.func.isRequired, 12 | value : PropTypes.string 13 | } 14 | 15 | static defaultProps = { value: '' }; 16 | 17 | state = { value: this.props.value }; 18 | 19 | constructor(...args) { 20 | super(...args); 21 | 22 | this.handleBlur = this.handleBlur.bind(this); 23 | this.handleChange = this.handleChange.bind(this); 24 | this.handleKeyDown = this.handleKeyDown.bind(this); 25 | } 26 | 27 | render() { 28 | return ( 29 | 39 | ); 40 | } 41 | 42 | _save() { 43 | this.props.onSave(this.state.value); 44 | this.setState({ 45 | value: '' 46 | }); 47 | } 48 | 49 | handleBlur() { 50 | this._save(); 51 | } 52 | 53 | handleChange(e) { 54 | this.setState({ 55 | value: e.target.value 56 | }); 57 | } 58 | 59 | handleKeyDown(e) { 60 | if (e.keyCode === ENTER_KEY_CODE) { 61 | this._save(); 62 | } 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/js/constants/todo.js: -------------------------------------------------------------------------------- 1 | import keyMirror from 'keymirror'; 2 | 3 | const todoConstant = keyMirror({ 4 | CREATE : null, 5 | UPDATE_TEXT : null, 6 | COMPLETE : null, 7 | UNDO_COMPLETE : null, 8 | TOGGLE_COMPLETE_ALL : null, 9 | DESTROY : null, 10 | DESTROY_COMPLETE : null, 11 | FETCH_TODOS : null, 12 | SYNC_TODOS : null, 13 | }); 14 | 15 | export default todoConstant 16 | -------------------------------------------------------------------------------- /src/js/dispatcher/TodoDispatcher.js: -------------------------------------------------------------------------------- 1 | // var Dispatcher = require('flux').Dispatcher; 2 | 3 | import Flux from 'flux'; 4 | 5 | module.exports = new Flux.Dispatcher(); 6 | -------------------------------------------------------------------------------- /src/js/stores/todoStore.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import TodoDispatcher from '../dispatcher/TodoDispatcher'; 3 | import todoConstants from '../constants/todo'; 4 | 5 | const CHANGE_EVENT = 'change'; 6 | 7 | let _todos = {}; 8 | 9 | function create(text) { 10 | let id = ('temp_' + (Math.random() * 999999 | 0)).toString(36); 11 | _todos[id] = { 12 | 'id': id, 13 | complete: false, 14 | text: text, 15 | }; 16 | } 17 | 18 | function update(id, updates) { 19 | _todos[id] = Object.assign({}, _todos[id], updates); 20 | } 21 | 22 | function updateAll(updates) { 23 | for (let id in _todos) { 24 | update(id, updates); 25 | } 26 | } 27 | 28 | function destroy(id) { 29 | delete _todos[id]; 30 | } 31 | 32 | function destroyCompleted() { 33 | for (let id in _todos) { 34 | if (_todos[id].complete) { 35 | destroy(id); 36 | } 37 | } 38 | } 39 | 40 | class TodoStore extends EventEmitter { 41 | 42 | constructor() { 43 | super(); 44 | 45 | TodoDispatcher.register(this.handler.bind(this)); 46 | } 47 | 48 | areAllComplete() { 49 | for (let id in _todos) { 50 | if (!_todos[id].complete) { 51 | return false; 52 | } 53 | } 54 | return true; 55 | } 56 | 57 | getAll() { 58 | return _todos; 59 | } 60 | 61 | emitChange() { 62 | this.emit(CHANGE_EVENT); 63 | } 64 | 65 | addChangeListener(callback) { 66 | this.on(CHANGE_EVENT, callback); 67 | } 68 | 69 | removeChangeListener(callback) { 70 | this.removeListener(CHANGE_EVENT, callback); 71 | } 72 | 73 | /** 74 | * Register callback to handle all updates 75 | * 76 | * @param {Object} action 77 | */ 78 | handler(action) { 79 | let text; 80 | 81 | switch (action.actionType) { 82 | case todoConstants.CREATE: 83 | text = action.text.trim(); 84 | if (text !== '') { 85 | create(text); 86 | this.emitChange(); 87 | } 88 | break; 89 | 90 | case todoConstants.UPDATE_TEXT: 91 | text = action.text.trim(); 92 | if (text !== '') { 93 | update(action.id, { text: text }); 94 | this.emitChange(); 95 | } 96 | break; 97 | 98 | case todoConstants.COMPLETE: 99 | update(action.id, { complete: true }); 100 | this.emitChange(); 101 | break; 102 | 103 | case todoConstants.UNDO_COMPLETE: 104 | update(action.id, { complete: false }); 105 | this.emitChange(); 106 | break; 107 | 108 | case todoConstants.TOGGLE_COMPLETE_ALL: 109 | if (this.areAllComplete()) { 110 | updateAll({ complete: false }); 111 | } else { 112 | updateAll({ complete: true }); 113 | } 114 | this.emitChange(); 115 | break; 116 | 117 | case todoConstants.DESTROY: 118 | destroy(action.id); 119 | this.emitChange(); 120 | break; 121 | 122 | case todoConstants.DESTROY_COMPLETED: 123 | destroyCompleted(); 124 | this.emitChange(); 125 | break; 126 | 127 | case todoConstants.FETCH_TODOS: 128 | case todoConstants.SYNC_TODOS: 129 | _todos = action.todos 130 | this.emitChange(); 131 | break; 132 | 133 | default: 134 | } 135 | } 136 | } 137 | 138 | const todoStore = new TodoStore(); 139 | 140 | export default todoStore; 141 | -------------------------------------------------------------------------------- /src/js/utils/todoApi.js: -------------------------------------------------------------------------------- 1 | import todoConstants from '../constants/todo'; 2 | import todoActions from '../actions/todoActions'; 3 | 4 | function _fetchTodos() { 5 | return new Promise((resolve, reject) => { 6 | let todos; 7 | try { 8 | todos = localStorage.getItem('todos'); 9 | 10 | if (!todos) { 11 | todos = {}; 12 | } else if (todos === 'undefined') { 13 | todos = {}; 14 | } else { 15 | todos = JSON.parse(todos); 16 | } 17 | 18 | resolve(todos); 19 | } catch (e) { 20 | reject(e); 21 | } 22 | }); 23 | } 24 | 25 | function _createTodo(text) { 26 | return new Promise((resolve, reject) => { 27 | let id = (Date.now() + (Math.random() * 999999 | 0)).toString(36); 28 | let newTodo = { 29 | 'id': id, 30 | text: text, 31 | complete: false 32 | }; 33 | 34 | _fetchTodos() 35 | .then((rawTodos) => { 36 | rawTodos[id] = newTodo; 37 | 38 | _saveTodos(rawTodos) 39 | .then((createdTodos) => { 40 | resolve({ 41 | todo: newTodo, 42 | todos: createdTodos 43 | }); 44 | }, _onError); 45 | 46 | }, _onError); 47 | }) 48 | .catch(_onError); 49 | } 50 | 51 | function _saveTodos(todos) { 52 | return new Promise((resolve, reject) => { 53 | try { 54 | localStorage.setItem('todos', JSON.stringify(todos)); 55 | resolve(todos); 56 | } catch (e) { 57 | reject(e); 58 | } 59 | }); 60 | } 61 | 62 | function _updateTodo(todo) { 63 | return new Promise((resolve, reject) => { 64 | 65 | if (!todo || !todo.id) { 66 | return reject(new Error('todo.id is empty on _updateTodo()')); 67 | } 68 | 69 | _fetchTodos() 70 | .then((todos) => { 71 | todos[todo.id] = Object.assign({}, todos[todo.id], { 72 | id: todo.id, 73 | text: todo.text, 74 | complete: todo.complete ? true : false 75 | }); 76 | 77 | _saveTodos(todos) 78 | .then((savedTodos) => { 79 | resolve(savedTodos); 80 | }, _onError); 81 | }) 82 | .catch(_onError); 83 | }); 84 | } 85 | 86 | function _toggleCompleteTodos() { 87 | return new Promise((resolve, reject) => { 88 | _fetchTodos() 89 | .then((todos) => { 90 | let isAllComplete = true; 91 | for (let id in todos) { 92 | if (!todos[id].complete) { 93 | isAllComplete = false; 94 | break; 95 | } 96 | } 97 | 98 | for (let id in todos) { 99 | todos[id] = Object.assign({}, todos[id], { 100 | complete: isAllComplete ? false : true 101 | }); 102 | } 103 | 104 | _saveTodos(todos) 105 | .then((savedTodos) => { 106 | resolve(savedTodos); 107 | }, _onError); 108 | }) 109 | .catch(_onError); 110 | }); 111 | } 112 | 113 | function _destroyTodo(id) { 114 | return new Promise((resolve, reject) => { 115 | 116 | if (!id) { 117 | return reject(new Error('id is empty on _destroy()')) 118 | } 119 | 120 | _fetchTodos() 121 | .then((todos) => { 122 | delete todos[id]; 123 | 124 | _saveTodos(todos) 125 | .then((savedTodos) => { 126 | resolve(savedTodos); 127 | }, _onError); 128 | }) 129 | .catch(_onError); 130 | }); 131 | } 132 | 133 | function _destroyCompletedTodos() { 134 | return new Promise((resolve, reject) => { 135 | _fetchTodos() 136 | .then((todos) => { 137 | for (let id in todos) { 138 | if (todos[id] && todos[id].complete) { 139 | delete todos[id]; 140 | } 141 | } 142 | 143 | _saveTodos(todos) 144 | .then((savedTodos) => { 145 | resolve(savedTodos); 146 | }, _onError); 147 | }) 148 | .catch(_onError); 149 | }); 150 | } 151 | 152 | function _onError(e) { 153 | alert(e); 154 | } 155 | 156 | export default { 157 | 158 | fetch: (callback) => { 159 | _fetchTodos() 160 | .then((todos) => { 161 | callback(todos); 162 | }) 163 | .catch(_onError); 164 | }, 165 | 166 | save: (text) => { 167 | _createTodo(text) 168 | .then((res) => { 169 | todoActions.syncTodos(res.todos); 170 | }) 171 | .catch(_onError); 172 | }, 173 | 174 | update: (params) => { 175 | _updateTodo(params) 176 | .then((todos) => { 177 | todoActions.syncTodos(todos); 178 | }) 179 | .catch(_onError); 180 | }, 181 | 182 | updateAll: (params) => { 183 | _fetchTodos() 184 | .then((rawTodos) => { 185 | for (let id in rawTodos) { 186 | rawTodos[id] = Object.assign({}, params, rawTodos[id]); 187 | } 188 | _saveTodos(rawTodos) 189 | .then((todos) => { 190 | todoActions.syncTodos(todos); 191 | }, _onError); 192 | }) 193 | .catch(_onError); 194 | }, 195 | 196 | toggleComplete: () => { 197 | _toggleCompleteTodos() 198 | .then((todos) => { 199 | todoActions.syncTodos(todos); 200 | }) 201 | .catch(_onError); 202 | }, 203 | 204 | destroy: (id) => { 205 | _destroyTodo(id) 206 | .then((todos) => { 207 | todoActions.syncTodos(todos); 208 | }) 209 | .catch(_onError); 210 | }, 211 | 212 | destroyCompleted: () => { 213 | _destroyCompletedTodos() 214 | .then((todos) => { 215 | todoActions.syncTodos(todos); 216 | }) 217 | .catch(_onError); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/styl/app.styl: -------------------------------------------------------------------------------- 1 | @import 'base/_reset' 2 | @import 'base/_core' 3 | 4 | #todo-list 5 | li 6 | .edit 7 | display inline 8 | -------------------------------------------------------------------------------- /src/styl/base/_core.styl: -------------------------------------------------------------------------------- 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('/img/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: -60px; 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 { 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 | @media (min-width: 899px) { 543 | .learn-bar { 544 | width: auto; 545 | margin: 0 0 0 300px; 546 | } 547 | 548 | .learn-bar > .learn { 549 | left: 8px; 550 | } 551 | 552 | .learn-bar #todoapp { 553 | width: 550px; 554 | margin: 130px auto 40px auto; 555 | } 556 | } -------------------------------------------------------------------------------- /src/styl/base/_reset.styl: -------------------------------------------------------------------------------- 1 | * 2 | margin 0 3 | padding 0 -------------------------------------------------------------------------------- /src/www/img/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sskyu/react-flux-todomvc-example/3de1cb57689a896248af1992dfe0896af05444a6/src/www/img/bg.png -------------------------------------------------------------------------------- /src/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | react-flux-todomvc-example 5 | 6 | 7 | 8 |
    9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | const src = './src'; 2 | const dest = './build'; 3 | 4 | module.exports = { 5 | entry: `${src}/js/app.js`, 6 | output: { 7 | path: `${dest}/js/`, 8 | filename: 'bundle.js' 9 | }, 10 | resolve: { 11 | extensions: ['', '.js'] 12 | }, 13 | module: { 14 | loaders: [ 15 | { 16 | test: /\.js$/, 17 | exclude: /node_modules/, 18 | loader: 'babel' 19 | } 20 | ] 21 | }, 22 | devtool: 'eval' 23 | }; 24 | --------------------------------------------------------------------------------