├── app ├── assets │ ├── css │ │ └── app.css │ └── images │ │ └── favicon.ico ├── index.html └── js │ ├── actions │ └── AppActionCreator.js │ ├── boot.js │ ├── constants │ └── AppConstants.js │ ├── dispatcher │ └── AppDispatcher.js │ ├── fixtures │ └── _readme.js │ ├── mixins │ ├── DirtyCheck.js │ ├── MixinManager.js │ └── Pushstate.js │ ├── stores │ └── TodoStore.js │ └── views │ ├── Detail.jsx │ ├── Footer.jsx │ ├── Header.jsx │ ├── InputBox.jsx │ ├── List.jsx │ ├── ListItem.jsx │ └── MainApp.jsx ├── gulpfile.js ├── package.json ├── readme.md └── server.js /app/assets/css/app.css: -------------------------------------------------------------------------------- 1 | 2 | .wrapper { 3 | background-color: #aadca8; 4 | width: 100%; 5 | position: absolute; 6 | left: 0; 7 | top: 0; 8 | right: 0; 9 | bottom: 0; 10 | } 11 | 12 | .logo { 13 | line-height: 48px; 14 | width: auto; 15 | display: inline-block; 16 | font-size: 20px; 17 | font-weight: bold; 18 | color: #F5F5F5; 19 | } 20 | 21 | .header { 22 | width: 100%; 23 | height: 48px; 24 | background-color: #A48163; 25 | position: absolute; 26 | top: 0; 27 | left: 0; 28 | padding: 0 10px; 29 | } 30 | 31 | .footer { 32 | position: absolute; 33 | left: 0; 34 | bottom: 0; 35 | width:100%; 36 | height: 36px; 37 | background-color: #A48163; 38 | } 39 | 40 | .footer span { 41 | color: white; 42 | display: block; 43 | text-align: center; 44 | line-height: 36px; 45 | } 46 | 47 | .search-box { 48 | /* line-height: 48px; */ 49 | margin-right: 10px; 50 | margin-top: 8px; 51 | } 52 | 53 | .main-box { 54 | width: 80%; 55 | margin: 80px auto; 56 | padding: 10px; 57 | background-color: #F0E68C 58 | } 59 | 60 | .input-box { 61 | width: 100%; 62 | height:34px; 63 | margin-bottom: 10px; 64 | } 65 | 66 | .input-box .search-input { 67 | width: 82%; 68 | height: 100%; 69 | } 70 | 71 | .save-button { 72 | width: 16%; 73 | height: 100%; 74 | } 75 | 76 | .todo-list { 77 | border: 1px solid gray; 78 | height: 200px; 79 | overflow: scroll; 80 | } 81 | 82 | .list-item { 83 | padding: 0px 14px; 84 | line-height: 40px; 85 | height: 40px; 86 | border-top: 1px solid gray; 87 | border-bottom: 1px solid gray; 88 | margin-top: -1px; 89 | } 90 | 91 | 92 | 93 | .list-item span { 94 | line-height: 36px; 95 | outline: 0; 96 | } 97 | 98 | .selected { 99 | background-color: #C2DAC2; 100 | } 101 | 102 | .boxo { 103 | border: 1px dashed red; 104 | } 105 | .hide { 106 | display: none; 107 | } 108 | 109 | .right { 110 | float: right; 111 | } 112 | 113 | /* 114 | Tablet 115 | 480-720px 116 | */ 117 | @media (max-width: 480px) { 118 | 119 | .main-box { 120 | position: absolute; 121 | top: 48px; 122 | left: 0; 123 | right: 0; 124 | bottom: 36px; 125 | width: 100%; 126 | margin: 0; 127 | padding: 10px; 128 | background-color: #F0E68C 129 | } 130 | 131 | .todo-list { 132 | border: 2px solid gray; 133 | height: auto; 134 | overflow: scroll; 135 | position: absolute; 136 | top: 55px; 137 | bottom: 10px; 138 | left: 10px; 139 | right: 10px; 140 | } 141 | 142 | } 143 | 144 | /* 145 | Desktop 146 | 一般應該是 1024+ 才進入此段 147 | 但為了示範方便,先寫 720px 148 | */ 149 | @media (min-width: 720px) { 150 | 151 | .todo-list { 152 | border: 2px solid gray; 153 | width: 120px; 154 | height: 200px; 155 | overflow: scroll; 156 | display: inline-block; 157 | } 158 | 159 | .item-detail { 160 | float: right; 161 | width: calc(100% - 140px); 162 | height: 200px; 163 | display: inline-block; 164 | } 165 | 166 | .item-detail button { 167 | margin-top: -45px; 168 | float: right; 169 | } 170 | 171 | } -------------------------------------------------------------------------------- /app/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coodoo/react-meetup-1/0854faf17e5fac6e41acb7b42016823a6bcf9a6b/app/assets/images/favicon.ico -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React/Flux 入門起手式範例程式 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/js/actions/AppActionCreator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | */ 4 | var AppDispatcher = require('../dispatcher/AppDispatcher'); 5 | var AppConstants = require('../constants/AppConstants'); 6 | var Promise = require('es6-promise').Promise; 7 | 8 | /** 9 | * 這是一個 singleton 物件 10 | */ 11 | var AppActionCreators = { 12 | 13 | /** 14 | * app 啟動後,第一次載入資料 15 | */ 16 | load: function(){ 17 | // 18 | }, 19 | 20 | /** 21 | * 22 | */ 23 | createTodo: function( item ) { 24 | 25 | // 1. 廣播給 store 知道去 optimistic 更新 view 26 | AppDispatcher.handleViewAction({ 27 | 28 | // type 是為了方便將來所有 Store 內部判斷是否要處理這個 action 29 | actionType: AppConstants.TODO_CREATE, 30 | 31 | // 這裏是真正要傳出去的值 32 | item: item 33 | }); 34 | 35 | }, 36 | 37 | /** 38 | * 39 | */ 40 | selectTodo: function( item ) { 41 | 42 | AppDispatcher.handleViewAction({ 43 | actionType: AppConstants.TODO_SELECT, 44 | item: item 45 | }); 46 | 47 | }, 48 | 49 | /** 50 | * 51 | */ 52 | removeTodo: function( item ) { 53 | 54 | AppDispatcher.handleViewAction({ 55 | actionType: AppConstants.TODO_REMOVE, 56 | item: item 57 | }); 58 | 59 | }, 60 | 61 | /** 62 | * 63 | */ 64 | updateTodo: function( item, newVal ) { 65 | 66 | AppDispatcher.handleViewAction({ 67 | actionType: AppConstants.TODO_UPDATE, 68 | item: item, 69 | newVal: newVal 70 | }); 71 | 72 | }, 73 | 74 | /** 75 | * 76 | */ 77 | doSearch: function( val ) { 78 | 79 | AppDispatcher.handleViewAction({ 80 | actionType: AppConstants.TODO_FILTER, 81 | val: val 82 | }); 83 | 84 | }, 85 | 86 | // dummy 87 | noop: function(){} 88 | }; 89 | 90 | module.exports = AppActionCreators; 91 | -------------------------------------------------------------------------------- /app/js/boot.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 這裏是整支程式的進入點,它負責建立 root view, 3 | * 也就是 MainApp 元件,將它建立起來放到畫面上 4 | * 5 | * boot.js 存在的目地,是因為通常 app 啟動時有許多先期工作要完成, 6 | * 例如預載資料到 store 內、檢查本地端 db 狀態、切換不同語系字串、 7 | * 這些工作都先在 boot.js 內做完,再啟動 root view 是比較理想的流程 8 | * 9 | */ 10 | 11 | // v0.12 開始要用 createFactory 包一次才能使用元件 12 | // 如果不希望這麼麻煩,只要在每份 js 裏都加下面這句即可,但它有缺點 13 | // var React = require('react'); 14 | // 15 | // 因為 require('...') 只是拿到一份元件定義檔,無法直接使用 16 | // 要用它建立一個 factory,之後才能產出 instance,下面 createFactory() 就是在建立工廠 17 | var MainApp = React.createFactory(require('./views/MainApp.jsx')); 18 | 19 | $(function(){ 20 | 21 | // 22 | React.render( MainApp(), document.getElementById('container') ); 23 | 24 | }) 25 | -------------------------------------------------------------------------------- /app/js/constants/AppConstants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * TodoConstants 3 | */ 4 | var keyMirror = function(obj) { 5 | var ret = {}; 6 | var key; 7 | for (key in obj) { 8 | if (!obj.hasOwnProperty(key)) { 9 | continue; 10 | } 11 | ret[key] = key; 12 | } 13 | return ret; 14 | }; 15 | 16 | // Constructs an enumeration with keys equal to their value. 17 | // 也就是讓 hash 的 key 與 value 值一樣 18 | // 不然原本 value 都是 null 19 | // 不過既然如此,為何不乾脆用 set 之類只有key 的就好 20 | module.exports = keyMirror({ 21 | 22 | SOURCE_VIEW_ACTION: null, 23 | SOURCE_SERVER_ACTION: null, 24 | SOURCE_ROUTER_ACTION: null, 25 | 26 | CHANGE_EVENT: null, 27 | 28 | TODO_CREATE: null, 29 | 30 | TODO_REMOVE: null, 31 | 32 | TODO_UPDATE: null, 33 | 34 | TODO_SELECT: null, 35 | 36 | TODO_FILTER: null, 37 | 38 | noop: null 39 | }); 40 | 41 | -------------------------------------------------------------------------------- /app/js/dispatcher/AppDispatcher.js: -------------------------------------------------------------------------------- 1 |  2 | var AppConstants = require('../constants/AppConstants'); 3 | 4 | var Dispatcher = require('flux').Dispatcher; 5 | 6 | 7 | /** 8 | * flux-chat 內最新的 dispatcher 9 | */ 10 | var AppDispatcher = new Dispatcher(); 11 | 12 | // 注意:這裏等於是繼承 Dispatcher class 身上所有指令,目地是讓此物件俱有廣播能功 13 | // 同樣功能也可用 underscore.extend 或 Object.assign() 做到 14 | // 今天因為有用 jquery 就請它代勞了 15 | $.extend( AppDispatcher, { 16 | 17 | /** 18 | * @param {object} action The details of the action, including the action's 19 | * type and additional data coming from the server. 20 | */ 21 | handleServerAction: function(action) { 22 | var payload = { 23 | source: AppConstants.SOURCE_SERVER_ACTION, 24 | action: action 25 | }; 26 | 27 | this.dispatch(payload); 28 | }, 29 | 30 | /** 31 | * 32 | */ 33 | handleViewAction: function(action) { 34 | var payload = { 35 | source: AppConstants.SOURCE_VIEW_ACTION, 36 | action: action 37 | }; 38 | 39 | this.dispatch(payload); 40 | }, 41 | 42 | /** 43 | * 將來啟用 router 時,這裏處理所有 router event 44 | */ 45 | handleRouterAction: function(path) { 46 | this.dispatch({ 47 | source: AppConstants.SOURCE_ROUTER_ACTION, 48 | action: path 49 | }); 50 | } 51 | 52 | }); 53 | 54 | module.exports = AppDispatcher; 55 | -------------------------------------------------------------------------------- /app/js/fixtures/_readme.js: -------------------------------------------------------------------------------- 1 | // 如果需要假資料供開發用,放這裏 2 | 3 | /*jshint maxlen:200 */ 4 | // 這是為了建立假資料,方便程式能跑起來,跟測試無關 5 | (function () { 6 | 'use strict'; 7 | 8 | App.Fixtures || (App.Fixtures = {}); 9 | 10 | App.Fixtures.Notes = [ 11 | { 12 | "createdAt": Date.UTC(2013, 1, 23, 0, 0, 0), 13 | "text": "## Recreation\n* Seattle Bouldering Project\n* Burke-Gilman Trail\n* Ballard Locks\n\n## Dining\n* Pies and Pints\n* Señor Moose's\n* Delancey\n* Dahlia Lounge", 14 | "title": "Things to do in Seattle." 15 | }, 16 | { 17 | "createdAt": Date.UTC(2013, 1, 23, 0, 0, 1), 18 | "text": "## Sights\n* Tidal Basin\n* National Mall\n* Rock Creek Trail\n\n## Cultural\n* National Portrait Gallery\n* National Gallery of Art\n* Hirshhorn Museum\n* The Phillips Collection", 19 | "title": "Things to do in Washington, DC." 20 | }, 21 | { 22 | "createdAt": Date.UTC(2013, 1, 23, 0, 0, 2), 23 | "text": "## Europe\n* Scotland\n* Ireland\n\n## South America\n* Patagonia\n* Peru", 24 | "title": "Places I want to go." 25 | } 26 | ]; 27 | 28 | }()); 29 | -------------------------------------------------------------------------------- /app/js/mixins/DirtyCheck.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | monitor: function( target, flag) { 4 | this.isDirty = false; 5 | 6 | // dbg( 'mooooo: ', target, flag ); 7 | 8 | Object.observe( target, function(changes) { 9 | // dbg( '\n\n\n有改 = ', changes ); 10 | if (changes.length > 0) 11 | this[flag] = true; 12 | }.bind(this)); 13 | } 14 | 15 | 16 | } 17 | -------------------------------------------------------------------------------- /app/js/mixins/MixinManager.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * react-mixin-manager v0.4.0 3 | * https://github.com/jhudson8/react-mixin-manager 4 | * 5 | * 6 | * Copyright (c) 2014 Joe Hudson 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy 9 | * of this software and associated documentation files (the "Software"), to deal 10 | * in the Software without restriction, including without limitation the rights 11 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | * copies of the Software, and to permit persons to whom the Software is 13 | * furnished to do so, subject to the following conditions: 14 | * 15 | * The above copyright notice and this permission notice shall be included in 16 | * all copies or substantial portions of the Software. 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 | */ 26 | (function(main) { 27 | if (typeof define === 'function' && define.amd) { 28 | define(['react'], main); 29 | } else if (typeof exports !== 'undefined' && typeof require !== 'undefined') { 30 | module.exports = function(React) { 31 | main(React); 32 | }; 33 | } else { 34 | main(React); 35 | } 36 | })(function(React) { 37 | 38 | /** 39 | * return the normalized mixin list 40 | * @param values {Array} list of mixin entries 41 | * @param index {Object} hash which contains a truthy value for all named mixins that have been added 42 | * @param rtn {Array} the normalized return array 43 | */ 44 | function get(values, index, rtn) { 45 | 46 | /** 47 | * add the named mixin and all un-added dependencies to the return array 48 | * @param the mixin name 49 | */ 50 | function addTo(name) { 51 | if (!index[name]) { 52 | var mixin = React.mixins._mixins[name], 53 | checkAgain = false; 54 | if (mixin) { 55 | if (typeof mixin === 'function') { 56 | mixin = mixin(); 57 | checkAgain = true; 58 | } 59 | get(React.mixins._dependsOn[name], index, rtn); 60 | get(React.mixins._dependsInjected[name], index, rtn); 61 | 62 | index[name] = true; 63 | if (checkAgain) { 64 | get([mixin], index, rtn); 65 | } else { 66 | rtn.push(mixin); 67 | } 68 | 69 | } else { 70 | throw 'invalid mixin "' + name + '"'; 71 | } 72 | } 73 | } 74 | 75 | function handleMixin(mixin) { 76 | if (mixin) { 77 | if (Array.isArray(mixin)) { 78 | // flatten it out 79 | get(mixin, index, rtn); 80 | } else if (typeof mixin === 'string') { 81 | // add the named mixin and all of it's dependencies 82 | addTo(mixin); 83 | } else { 84 | // just add the mixin normally 85 | rtn.push(mixin); 86 | } 87 | } 88 | } 89 | 90 | if (Array.isArray(values)) { 91 | for (var i=0; i 40 | 41 |
42 | 43 | 48 |
49 | 50 |
51 | 52 |

{ date }

53 |
54 | 55 |
56 | 57 |

{this.state.selectedItem.uid}

58 |
59 | 60 | 62 | 63 | 64 | ); 65 | 66 | }, 67 | 68 | /** 69 | * 大部份 ui 操作最終都是直接轉手給 actions 去處理 70 | */ 71 | handleClick: function( evt ){ 72 | evt.preventDefault(); 73 | // console.log( '\n\nsave button click: ', this.state.selectedItem.name ); 74 | actions.updateTodo(this.state.selectedItem, this.state.selectedItem.name); 75 | }, 76 | 77 | 78 | /** 79 | * input 是 controlled component 80 | * 它的值是綁定在 this.state.selectedItem 身上 81 | * 因此要在 change 時將新值設回 selectedItem 內,才會顯示在畫面上 82 | */ 83 | handleChange: function(field, evt){ 84 | this.state.selectedItem[field] = evt.target.value; 85 | this.setState( {selectedItem: this.state.selectedItem} ); 86 | }, 87 | 88 | // 89 | noop: function(){ 90 | console.log( 'd: ', new Date(this.state.selectedItem.created) ); 91 | } 92 | 93 | }); 94 | 95 | module.exports = comp; -------------------------------------------------------------------------------- /app/js/views/Footer.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | */ 4 | var actions = require('../actions/AppActionCreator'); 5 | 6 | /** 7 | * 8 | */ 9 | var Footer = React.createClass({ 10 | 11 | 12 | /** 13 | * 14 | */ 15 | render: function() { 16 | 17 | return ( 18 |
19 | 20 | MIT licensing, use at will. 21 | 22 |
23 | ); 24 | }, 25 | 26 | 27 | }); 28 | 29 | module.exports = Footer; 30 | -------------------------------------------------------------------------------- /app/js/views/Header.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | */ 4 | var actions = require('../actions/AppActionCreator'); 5 | 6 | /** 7 | * 8 | */ 9 | var Header = React.createClass({ 10 | 11 | /** 12 | * 13 | */ 14 | render: function() { 15 | 16 | 17 | return ( 18 | 19 |
20 | 21 |

Todo for Dummies

22 | 23 | 27 | 28 |
29 | ); 30 | 31 | }, 32 | 33 | /** 34 | * 35 | */ 36 | handleChange: function(evt){ 37 | var val = evt.target.value.trim(); 38 | actions.doSearch(val); 39 | }, 40 | 41 | // 42 | noop: function(){ 43 | } 44 | 45 | }); 46 | 47 | module.exports = Header; -------------------------------------------------------------------------------- /app/js/views/InputBox.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | */ 4 | var shortId = require('shortid'); 5 | var actions = require('../actions/AppActionCreator'); 6 | 7 | /** 8 | * 9 | */ 10 | var comp = React.createClass({ 11 | 12 | componentDidMount: function(){ 13 | this.$input = $('#todo-input'); 14 | }, 15 | 16 | /** 17 | * supported events 18 | * http://facebook.github.io/react/docs/events.html 19 | */ 20 | render: function() { 21 | 22 | return ( 23 | 24 |
25 | 26 | 33 | 34 | 35 | 36 |
37 | ); 38 | 39 | }, 40 | 41 | 42 | /** 43 | * 按下 enter 就存檔 44 | */ 45 | handleKeyDown: function(evt){ 46 | if( evt.keyCode == 13){ 47 | this.handleSave(); 48 | } 49 | }, 50 | 51 | /** 52 | * 按下 save 鈕就存檔 53 | */ 54 | handleSave: function(evt){ 55 | 56 | var val = this.$input.val(); 57 | 58 | // 未輸入文字的話就擋掉 59 | if( val.trim().length == 0 ) return; 60 | 61 | var item = {}; 62 | item.name = val; 63 | item.uid = shortId.generate(); 64 | item.created = Date.now(); 65 | 66 | actions.createTodo( item ); 67 | 68 | // 清空輸入框,等待下一次的輸入 69 | this.$input.val(''); 70 | }, 71 | 72 | noop: function(){} 73 | 74 | }); 75 | 76 | module.exports = comp; -------------------------------------------------------------------------------- /app/js/views/List.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | */ 4 | var actions = require('../actions/AppActionCreator'); 5 | var ListItem = React.createFactory(require('./ListItem.jsx')); 6 | 7 | /** 8 | * 9 | */ 10 | var comp = React.createClass({ 11 | 12 | /** 13 | * 新增的 item 會在 list 最底部,有可能超出捲動範圍而看不到, 14 | * 因此要自動往下捲使其可視 15 | * 但同時也要避免一般的點選行為出現亂跳現象,因此要精準判斷該物件是否需要被捲動 16 | */ 17 | componentDidUpdate: function(){ 18 | 19 | // 下面都是在操作 DOM api,因此一開始就不用 jquery 選取,會便宜一些 20 | var elem = document.querySelector('.todo-list .selected'); 21 | 22 | // 當前沒有選取任何項目就不繼續了 23 | if(!elem) return; 24 | 25 | var parent = elem.parentElement; 26 | 27 | // 10 是安全區間,避免有時判斷失靈導致該顯示而沒捲動 28 | if( elem.getBoundingClientRect().top - parent.getBoundingClientRect().bottom > -10 ){ 29 | elem.scrollIntoView(); 30 | } 31 | 32 | }, 33 | 34 | /** 35 | * 36 | */ 37 | render: function() { 38 | 39 | var arrTodos = this.props.truth.arrTodos; 40 | var filterStr = this.props.truth.filter; 41 | 42 | // 接著針對 arr 做 filter() 與 map() 兩段處理 43 | var arr = arrTodos 44 | 45 | // 先依隨打即查關鍵字過濾 46 | .filter(function(item){ 47 | return item.name.indexOf(filterStr) != -1; 48 | }) 49 | 50 | // 再將合格的項目轉成 元件供顯示 51 | .map(function(item){ 52 | 53 | // 54 | return 62 | 63 | }, this) 64 | 65 | // 當上面這段跑完時,arr[] 的內容會就是一包 元件 66 | // 下面就可直接使用 67 | 68 | return ( 69 | 70 |
71 | {arr} 72 |
73 | ); 74 | 75 | }, 76 | 77 | /** 78 | * 大部份 ui 操作最終都是直接轉手給 actions 去處理 79 | */ 80 | handleClick: function( item ){ 81 | // console.log( '\n\nitem click: ', item.name ); 82 | actions.selectTodo(item); 83 | }, 84 | 85 | /** 86 | * 87 | */ 88 | handleRemove: function( item ){ 89 | actions.removeTodo(item); 90 | }, 91 | 92 | // 93 | noop: function(){} 94 | 95 | }); 96 | 97 | module.exports = comp; -------------------------------------------------------------------------------- /app/js/views/ListItem.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | */ 4 | var actions = require('../actions/AppActionCreator'); 5 | var cx = React.addons.classSet; 6 | 7 | /** 8 | * 9 | */ 10 | var comp = React.createClass({ 11 | 12 | /** 13 | * didMount 代表 react 元件已出現在 DOM 上, 14 | * 預先將後面常用到的 elem 選出來存著,避免將來重覆選取 15 | */ 16 | componentDidMount: function(){ 17 | this.$input = $(this.getDOMNode()).find('span').first(); 18 | this.$remove = this.$input.next(); 19 | }, 20 | 21 | /** 22 | * 23 | */ 24 | render: function() { 25 | 26 | // 這裏使用 react class add-on 來切換樣式顯示 27 | // 這樣做比較有條理,比直接組合多個字串來的好控制 28 | var classes = cx({ 29 | 'list-item': true, 30 | 'selected': this.props.selected 31 | }); 32 | 33 | return ( 34 | 35 |
44 | 45 | {this.props.todoItem.name} 46 | 47 | 49 | 50 |
51 | ); 52 | 53 | }, 54 | 55 | /** 56 | * 在 listItem 上雙響時要切換為編輯模式 57 | * 手法是加上 contenteditable 屬性 58 | * 這也代表只支援 modern browsers 59 | */ 60 | handleDblClick: function(){ 61 | 62 | var val = null; 63 | 64 | // 加上這屬性就可編輯元件 65 | this.$input.attr('contenteditable', true); 66 | 67 | // 將 I-beam 放到文字最後方 68 | this.setCaret(); 69 | 70 | // 編輯結束後的處理流程 71 | this.$input.on('keydown focusout', function(evt){ 72 | 73 | // enter key 或 文字框喪失focus 事件發生,即認定為退出編輯狀態 74 | if( evt.keyCode == 13 || evt.type == 'focusout' ){ 75 | 76 | evt.preventDefault(); 77 | 78 | // 取得編輯後的新值 79 | val = this.$input.text(); 80 | 81 | // 移除 的編輯能力 82 | this.$input.removeAttr('contenteditable'); 83 | // 也解掉掛的偵聽 84 | this.$input.off('keydown focusout'); 85 | 86 | // 準備將新值存入 store,方法一樣是操作 actionCreator 87 | // this.props.todoItem.name = val; 88 | actions.updateTodo( this.props.todoItem, val ); 89 | 90 | } 91 | }.bind(this)) 92 | }, 93 | 94 | /** 95 | * util: 設定 I-beam 位置 96 | */ 97 | setCaret: function() { 98 | var el = this.$input[0]; 99 | var range = document.createRange(); 100 | var sel = window.getSelection(); 101 | range.setStart(el.childNodes[0], el.innerText.length); 102 | range.collapse(true); 103 | sel.removeAllRanges(); 104 | sel.addRange(range); 105 | el.focus(); 106 | }, 107 | 108 | 109 | /** 110 | * ListItem 內部預先處理過刪除事件 111 | */ 112 | handleRemove: function(evt){ 113 | 114 | // 停止此事件繼續向上廣播,不然會連帶觸發另個 onClick 事件 115 | evt.stopPropagation(); 116 | 117 | // 如果外界有傳入 onRemove handler,就觸發它,並且將自已身份也傳出去,方便外界識別與處理 118 | if( this.props.onRemove ){ 119 | this.props.onRemove(this.props.todoItem); 120 | } 121 | 122 | }, 123 | 124 | /** 125 | * 滑鼠移到一個 item 時要顯示 ✖ 鈕供刪除 126 | * 並且滑鼠移開時要隱藏 127 | */ 128 | handleMouseMovement: function(evt){ 129 | if( evt.type == 'mouseover'){ 130 | this.$remove.removeClass('hide') 131 | }else{ 132 | this.$remove.addClass('hide') 133 | } 134 | }, 135 | 136 | noop: function(){} 137 | 138 | }); 139 | 140 | module.exports = comp; -------------------------------------------------------------------------------- /app/js/views/MainApp.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 這是 root view,也稱為 controller-view 3 | */ 4 | 5 | 6 | //======================================================================== 7 | // 8 | // import 9 | 10 | // var React = require('react'); 11 | var Header = React.createFactory( require('./Header.jsx') ); 12 | var Footer = React.createFactory( require('./Footer.jsx') ); 13 | var InputBox = React.createFactory( require('./InputBox.jsx') ); 14 | var List = React.createFactory( require('./List.jsx') ); 15 | var Detail = React.createFactory( require('./Detail.jsx') ); 16 | 17 | var TodoStore = require('../stores/TodoStore'); 18 | var AppConstants = require('../constants/AppConstants'); 19 | 20 | var idResize; 21 | 22 | /** 23 | * 24 | */ 25 | var MainApp = React.createClass({ 26 | 27 | //======================================================================== 28 | // 29 | // mount 30 | 31 | /** 32 | * 這是 component API, 在 mount 前會跑一次,取值做為 this.state 的預設值 33 | */ 34 | getInitialState: function() { 35 | var o = this.getTruth(); 36 | o.screenSize = 'tablet' 37 | return o; 38 | }, 39 | 40 | /** 41 | * 主程式進入點 42 | */ 43 | componentWillMount: function() { 44 | TodoStore.addListener( AppConstants.CHANGE_EVENT, this._onChange ); 45 | 46 | // 要用 interval 擋一下 47 | window.addEventListener('resize', this.handleResize ); 48 | 49 | this.handleResize(); 50 | }, 51 | 52 | handleResize: function(evt){ 53 | 54 | clearTimeout( idResize ); 55 | 56 | idResize = setTimeout(function(){ 57 | 58 | var body = document.body; 59 | var size; 60 | 61 | // @todo: 改回 1024 62 | if(body.scrollWidth > 720){ 63 | size = 'desktop'; 64 | }else if(body.scrollWidth > 480){ 65 | size = 'tablet'; 66 | }else{ 67 | size = 'phone'; 68 | } 69 | 70 | // console.log( 'resize: ', body.scrollWidth, body.scrollHeight, ' >size: ', size ); 71 | 72 | this.setState({screenSize: size}); 73 | 74 | }.bind(this), 0) 75 | 76 | }, 77 | 78 | /** 79 | * 重要:root view 建立後第一件事,就是偵聽 store 的 change 事件 80 | */ 81 | componentDidMount: function() { 82 | // 83 | }, 84 | 85 | //======================================================================== 86 | // 87 | // unmount 88 | 89 | /** 90 | * 元件將從畫面上移除時,要做善後工作 91 | */ 92 | componentWillUnmount: function() { 93 | TodoStore.removeChangeListener( this._onChange ); 94 | }, 95 | 96 | /** 97 | * 98 | */ 99 | componentDidUnmount: function() { 100 | // 101 | }, 102 | 103 | //======================================================================== 104 | // 105 | // update 106 | 107 | /** 108 | * 在 render() 前執行,有機會可先處理 props 後用 setState() 存起來 109 | */ 110 | componentWillReceiveProps: function(nextProps) { 111 | // 112 | }, 113 | 114 | /** 115 | * 116 | */ 117 | shouldComponentUpdate: function(nextProps, nextState) { 118 | return true; 119 | }, 120 | 121 | // 這時已不可用 setState() 122 | componentWillUpdate: function(nextProps, nextState) { 123 | }, 124 | 125 | /** 126 | * 127 | */ 128 | componentDidUpdate: function(prevProps, prevState) { 129 | }, 130 | 131 | //======================================================================== 132 | // 133 | // render 134 | 135 | /** 136 | * 137 | */ 138 | render: function() { 139 | 140 | var size = this.state.screenSize; 141 | // console.log( 'size: ', size ); 142 | 143 | if( size == 'phone' ){ 144 | 145 | // phone 146 | return ( 147 | 148 |
149 | 150 |
151 | 152 |
153 | 154 | 155 |
156 | 157 |
158 |
159 | ) 160 | 161 | }else if( size == 'tablet'){ 162 | 163 | // tablet 164 | return ( 165 | 166 |
167 | 168 |
169 | 170 |
171 | 172 | 173 |
174 | 175 |
176 |
177 | ) 178 | 179 | }else{ 180 | 181 | // desktop 182 | return ( 183 | 184 |
185 | 186 |
187 | 188 |
189 | 190 | 191 | 192 |
193 | 194 |
196 | ) 197 | } 198 | }, 199 | 200 | 201 | 202 | //======================================================================== 203 | // 204 | // private methods - 處理元件內部的事件 205 | 206 | /** 207 | * controller-view 偵聽到 model change 後 208 | * 執行這支,它操作另一支 private method 去跟 model 取最新值 209 | * 然後操作 component life cycle 的 setState() 將新值灌入元件體系 210 | * 就會觸發一連串 child components 跟著重繪 211 | */ 212 | _onChange: function(){ 213 | // 重要:從 root view 觸發所有 sub-view 重繪 214 | this.setState( this.getTruth() ); 215 | }, 216 | 217 | /** 218 | * 為何要獨立寫一支?因為會有兩個地方會用到,因此抽出來 219 | * 目地:向各個 store 取回資料,然後統一 setState() 再一層層往下傳遞 220 | */ 221 | getTruth: function() { 222 | // 是從 TodoStore 取資料(as the single source of truth) 223 | return TodoStore.getAll(); 224 | } 225 | 226 | 227 | }); 228 | 229 | module.exports = MainApp; 230 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var browserify = require('browserify'); 3 | var source = require('vinyl-source-stream'); 4 | var minifyCSS = require('gulp-minify-css'); 5 | var livereload = require('gulp-livereload'); 6 | var notify = require('gulp-notify'); 7 | var fs = require('fs'); 8 | 9 | // 環境變數 10 | var env = 'prod'; // dev||prod 11 | 12 | var live = livereload(); 13 | livereload.listen(); 14 | 15 | // 路徑變數 16 | var paths = { 17 | main: './app/js/boot.js', 18 | css: './app/assets/css/*.css', 19 | destDir: 'build', 20 | destCSS: 'build/assets/css' 21 | }; 22 | 23 | /** 24 | * 25 | */ 26 | gulp.task('bundle-js', function() { 27 | 28 | // console.log( '\nbundle-js 跑' ); 29 | 30 | return browserify({ 31 | entries:[ paths.main ] 32 | }) 33 | 34 | // 最優先編譯 jsx,確保後面其它 transform 運行無誤 35 | .transform( 'reactify' ) 36 | 37 | // 所有檔案合併為一,並指定要生成 source map 38 | .bundle({debug: true}) 39 | 40 | .on('error', function( err ){ 41 | console.log( '[錯誤]', err ); 42 | this.end(); 43 | gulp.src('').pipe( notify('✖ Bunlde Failed ✖') ) 44 | }) 45 | 46 | // 利用 vinyl-source-stream 幫檔案取名字 47 | .pipe( source('bundle.js') ) 48 | 49 | // 接著就回到 gulp 系統做剩下事 50 | // 這裏是直接存檔到硬碟 51 | .pipe( gulp.dest('./build') ) 52 | 53 | }); 54 | 55 | /** 56 | * 縮短 app.css 57 | */ 58 | gulp.task('minify-css', function() { 59 | gulp.src( paths.css ) 60 | .pipe(minifyCSS( 61 | { 62 | noAdvanced: false, 63 | keepBreaks:true, 64 | cache: true // 這是 gulp 插件獨有的 65 | })) 66 | .pipe(gulp.dest( paths.destCSS )) 67 | }); 68 | 69 | 70 | /** 71 | * 將 index.html 與 css/ 複製到 build/ 下面 72 | * 才方便測試 73 | */ 74 | gulp.task('copy', function(){ 75 | return gulp.src([ 'app/index.html' ], { base: 'app' } ) 76 | .pipe( gulp.dest(paths.destDir)); 77 | }) 78 | 79 | 80 | /** 81 | * 監控 app/ 下所有 js, jsx, html, css 變化就重新編譯 82 | */ 83 | gulp.task('watch', function() { 84 | // console.log( 'watch 跑' ); 85 | 86 | gulp.watch( 'app/**/*', ['bundle-js', 'minify-css', 'copy', 'refresh'] ); 87 | }); 88 | 89 | /** 90 | * livereload refresh 91 | */ 92 | gulp.task( 'refresh', function(){ 93 | // console.log( '\nlivereload > refresh\n' ); 94 | setTimeout(function(){ 95 | live.changed(''); 96 | }, 500) 97 | }) 98 | 99 | 100 | //======================================================================== 101 | // 102 | // 總成的指令集 103 | 104 | 105 | /** 106 | * 初期讓 default 就是跑 dev task,將來可能會改成有 build, deploy 等花樣 107 | */ 108 | gulp.task('default', ['dev']); 109 | 110 | /** 111 | * 編譯與打包 jsx 為一張檔案 112 | * 廣播 livereload 事件 113 | * 啟動 8000 server 供本地跑 114 | */ 115 | gulp.task('dev', ['bundle-js', 'minify-css', 'copy', 'watch'] ); 116 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Meetup", 3 | "version": "1.0.0", 4 | "description": "Sample application for meetup", 5 | "main": "js/app.js", 6 | "dependencies": { 7 | "debug": "^2.1.0", 8 | "es6-promise": "~0.1.1", 9 | "flux": "^2.0.1", 10 | "gulp-livereload": "^2.1.1", 11 | "gulp-minify-css": "^0.3.11", 12 | "gulp-notify": "^2.0.0", 13 | "shortid": "^2.0.1", 14 | "vinyl-source-stream": "^1.0.0" 15 | }, 16 | "devDependencies": { 17 | "browserify": "^4.2.0", 18 | "connect": "^2.17.1", 19 | "gulp": "^3.8.5", 20 | "reactify": "^0.13.1" 21 | }, 22 | "scripts": { 23 | "start": "gulp", 24 | "test": "echo \"Error: no test specified\" && exit 1" 25 | }, 26 | "author": "Jeremy Lu", 27 | "license": "MIT" 28 | } 29 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## 執行方式 4 | 5 | `npm install` 安裝所有套件 6 | 7 | `node server` 啟動 local web server 8 | 9 | `npm start` 開始 bundle + watch 10 | 11 | `http://localhost:8000/` 即可看到畫面 12 | 13 | ## 課程講義 14 | 15 | http://goo.gl/625c0e 16 | 17 | ## 線上試用 18 | 19 | http://coodoo.github.io/react-meetup-1/ 20 | 21 | ## Branch 結構 22 | 23 | - Master 內是最終成品,包含所有功能與修正 24 | 25 | - e0 是最基本的 CRUD 功能,課程將從此 branch 開始介紹 26 | 27 | - e1 - e4 是以 e0 為基礎而逐步新增的功能 28 | 29 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var connect = require('connect'), 2 | http = require('http'), 3 | fs = require('fs'), 4 | app; 5 | 6 | // 建 server 7 | app = connect() 8 | 9 | // 指定供應 static file 的路徑 10 | // request 進來,先去 static 目錄找,沒有符合的才進入下一個 middleware 11 | .use( connect.static('build') ) 12 | 13 | .use( function(req, res, next ){ 14 | fs.readFile('./build/index.html', function(err, data){ 15 | res.write(data); 16 | res.end(); 17 | }) 18 | }) 19 | 20 | // 啟動 server 在 8000 21 | http.createServer(app).listen(8000, function() { 22 | console.log('Running on http://localhost:8000'); 23 | }); --------------------------------------------------------------------------------