├── .gitignore ├── .jshintrc ├── LICENSE.md ├── NEWS.md ├── README.md ├── gulpfile.js ├── package.json ├── public ├── css │ └── style.css └── index.html ├── reactodo.png ├── src ├── Constants.js ├── Page.js ├── app.jsx ├── components │ ├── Project.jsx │ ├── Reactodo.jsx │ ├── Settings.jsx │ ├── TodoItem.jsx │ ├── Welcome.jsx │ └── reusable │ │ └── EditInput.jsx └── utils │ ├── Base64.js │ ├── classNames.js │ ├── exportProject.js │ ├── exportTextFile.js │ ├── extend.js │ ├── normaliseContentEditableHTML.js │ ├── partial.js │ ├── trim.js │ └── uuid.js └── vendor ├── react-0.11.2.js └── react-0.11.2.min.js /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | node_modules/ -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "browser": true, 3 | "node": true, 4 | "jquery": true, 5 | 6 | "curly": true, 7 | "devel": true, 8 | "globals": { 9 | "React": true, 10 | "validator": true 11 | }, 12 | "noempty": true, 13 | "newcap": false, 14 | "undef": true, 15 | "unused": "vars", 16 | 17 | "asi": true, 18 | "boss": true, 19 | "eqnull": true, 20 | "expr": true, 21 | "funcscope": true, 22 | "globalstrict": true, 23 | "laxbreak": true, 24 | "laxcomma": true, 25 | "sub": true 26 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | ----------- 3 | 4 | Copyright (c) 2014, Jonathan Buchanan 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /NEWS.md: -------------------------------------------------------------------------------- 1 | 0.2.3 – 2014-11-27 2 | ------------------------ 3 | 4 | * Upgraded to React 0.12.1. 5 | 6 | 0.2.2 – 2014-03-23 7 | ------------------------ 8 | 9 | * Upgraded to React 0.10.0. 10 | 11 | 0.2.1 – 2014-02-21 12 | ------------------------ 13 | 14 | * Upgraded to React 0.9.0. 15 | 16 | 0.2.0 – 2014-02-07 17 | ------------------------ 18 | 19 | * **Backwards-incompatible change** – linebreaks are now stored as `
` 20 | rather than `\n` – long lines will now wrap. 21 | 22 | * Fixed: clicking to edit one project name then another no longer copies the 23 | former's name to the latter. 24 | 25 | * All HTML tags except the `
`s used for linebreaks are now stripped after 26 | editing a TODO. In browsers which insert a `

` or `

` when you press 27 | Enter in a `contentEditable`, these are normalised to `
` before saving. 28 | 29 | * Sessions can now be switched on the fly without reloading the page. 30 | 31 | * Added a quick session switching dropdown to the header, which activates when 32 | there are multiple sessions available. 33 | 34 | * Added exporting of projects as a `.todo.txt` file. 35 | 36 | * Added session management to the settings screen - add, rename and delete. 37 | 38 | * Session switcher `` on the Welcome page now uses a `` to 39 | suggest completions for existing session names from `localStorage`. 40 | 41 | * The active session name is now displayed in ``. 42 | 43 | 0.1.0 – 2014-01-28 44 | ------------------------ 45 | 46 | * Initial release. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # reactodo 2 | 3 | Multiple localStorage TODO lists, built with [React](http://facebook.github.io/react) 4 | 5 | * http://insin.github.io/reactodo/ 6 | 7 | Designed and styled after quick, disposable TODO tracking of the type commonly 8 | done in a programmer's text editor, but with a few interactive niceties to 9 | manage items instead of cut + paste. 10 | 11 | ![reactodo screenshot](reactodo.png) 12 | 13 | ## Build 14 | 15 | Install dependencies: 16 | 17 | ``` 18 | npm install 19 | ``` 20 | 21 | Develop: 22 | 23 | ``` 24 | npm run dev 25 | ``` 26 | 27 | Production build: 28 | 29 | ``` 30 | npm run dist 31 | ``` 32 | 33 | ## MIT Licensed 34 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | 3 | var browserify = require('browserify') 4 | var del = require('del') 5 | var glob = require('glob') 6 | var gulp = require('gulp') 7 | var source = require('vinyl-source-stream') 8 | 9 | var concat = require('gulp-concat') 10 | var flatten = require('gulp-flatten') 11 | var jshint = require('gulp-jshint') 12 | var plumber = require('gulp-plumber') 13 | var react = require('gulp-react') 14 | var rename = require('gulp-rename') 15 | var streamify = require('gulp-streamify') 16 | var template = require('gulp-template') 17 | var uglify = require('gulp-uglify') 18 | var gutil = require('gulp-util') 19 | 20 | process.env.NODE_ENV = gutil.env.production ? 'production' : 'development' 21 | 22 | var version = require('./package.json').version 23 | var jsSrc = ['./src/**/*.js', './src/**/*.jsx'] 24 | var jsExt = (gutil.env.production ? 'min.js' : 'js') 25 | 26 | gulp.task('clean', function(cb) { 27 | del(['./build/**/*', './dist/**/*'], cb) 28 | }) 29 | 30 | gulp.task('transpile-js', function() { 31 | return gulp.src(jsSrc) 32 | .pipe(plumber()) 33 | .pipe(react({ 34 | harmony: true 35 | })) 36 | .pipe(flatten()) 37 | .pipe(gulp.dest('./build/modules')) 38 | }) 39 | 40 | gulp.task('lint', ['transpile-js'], function() { 41 | return gulp.src('./build/modules/*.js') 42 | .pipe(jshint('./.jshintrc')) 43 | .pipe(jshint.reporter('jshint-stylish')) 44 | }) 45 | 46 | gulp.task('bundle-js', ['lint'], function() { 47 | var b = browserify('./build/modules/app.js', { 48 | debug: !gutil.env.production 49 | , detectGlobals: false 50 | }) 51 | b.external('react') 52 | 53 | // Expose each module as a bare require, because 54 | glob.sync('./build/modules/*.js').forEach(function(module) { 55 | var expose = module.split('/').pop().split('.').shift() 56 | if (expose != 'app') { 57 | b.require(module, {expose: expose}) 58 | } 59 | }) 60 | 61 | var stream = b.bundle() 62 | .pipe(source('app.js')) 63 | .pipe(gulp.dest('./build')) 64 | 65 | if (gutil.env.production) { 66 | stream = stream 67 | .pipe(rename('app.min.js')) 68 | .pipe(streamify(uglify())) 69 | .pipe(gulp.dest('./build')) 70 | } 71 | 72 | return stream 73 | }) 74 | 75 | gulp.task('bundle-deps', function() { 76 | var b = browserify({detectGlobals: false}) 77 | b.require('react') 78 | b.transform('envify') 79 | 80 | var stream = b.bundle() 81 | .pipe(source('deps.js')) 82 | .pipe(gulp.dest('./build')) 83 | 84 | if (gutil.env.production) { 85 | stream = stream.pipe(rename('deps.min.js')) 86 | .pipe(streamify(uglify())) 87 | .pipe(gulp.dest('./build')) 88 | } 89 | 90 | return stream 91 | }) 92 | 93 | gulp.task('dist-js', ['bundle-js'], function() { 94 | return gulp.src('./build/app.' + jsExt) 95 | .pipe(gulp.dest('./dist/js')) 96 | }) 97 | 98 | gulp.task('dist-deps', ['bundle-deps'], function() { 99 | return gulp.src('./build/deps.' + jsExt) 100 | .pipe(gulp.dest('./dist/js')) 101 | }) 102 | 103 | gulp.task('dist-css', function() { 104 | return gulp.src('./public/css/*.css') 105 | .pipe(gulp.dest('./dist/css')) 106 | }) 107 | 108 | gulp.task('dist-html', function() { 109 | return gulp.src('./public/index.html') 110 | .pipe(template({ 111 | version: version 112 | , jsExt: jsExt 113 | })) 114 | .pipe(gulp.dest('./dist')) 115 | }) 116 | 117 | gulp.task('dist', ['dist-js', 'dist-deps', 'dist-css', 'dist-html']) 118 | 119 | gulp.task('watch', function() { 120 | gulp.watch(jsSrc, ['dist-js']) 121 | gulp.watch('./public/css/*.css', ['dist-css']) 122 | gulp.watch('./public/index.html', ['dist-html']) 123 | }) 124 | 125 | gulp.task('default', ['dist', 'watch']) 126 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactodo", 3 | "version": "0.2.4", 4 | "description": "Multiple localStorage TODO lists, built with React", 5 | "scripts": { 6 | "dev": "gulp clean && gulp", 7 | "dist": "gulp clean && gulp dist --production" 8 | }, 9 | "dependencies": { 10 | "react": "~0.12.2" 11 | }, 12 | "devDependencies": { 13 | "browserify": "^8.1.3", 14 | "del": "^1.1.1", 15 | "envify": "^3.2.0", 16 | "glob": "^4.3.5", 17 | "gulp": "^3.8.10", 18 | "gulp-concat": "^2.4.3", 19 | "gulp-flatten": "0.0.4", 20 | "gulp-jshint": "^1.9.2", 21 | "gulp-plumber": "^0.6.6", 22 | "gulp-react": "^2.0.0", 23 | "gulp-rename": "^1.2.0", 24 | "gulp-streamify": "0.0.5", 25 | "gulp-template": "^2.1.0", 26 | "gulp-uglify": "^1.1.0", 27 | "gulp-util": "^3.0.3", 28 | "jshint-stylish": "^1.0.0", 29 | "vinyl-source-stream": "^1.0.0" 30 | }, 31 | "author": "Jonathan Buchanan <jonathan.buchanan@gmail.com>", 32 | "repository": { 33 | "type": "git", 34 | "url": "http://github.com/insin/reactodo.git" 35 | }, 36 | "license": "MIT" 37 | } 38 | -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 1em 2em; 3 | margin: 0; 4 | background-color: #272822; 5 | color: #f8f8f2; 6 | font-family: consolas, monospace; 7 | font-size: 16px; 8 | } 9 | 10 | a { 11 | color: #f8f8f2; 12 | } 13 | 14 | h1 { 15 | margin: 0 0 16px 0; 16 | } 17 | 18 | h1 small { 19 | color: #5b5c56; 20 | font-size: 24px; 21 | } 22 | 23 | h2 { 24 | margin: 8px 0; 25 | font-size: 1em; 26 | } 27 | 28 | h2:first-child { 29 | margin-top: 0; 30 | } 31 | 32 | h2:last-child { 33 | margin-bottom: 0; 34 | } 35 | 36 | td, th { 37 | padding: 5px 10px; 38 | text-align: left; 39 | } 40 | 41 | input[type=text] { 42 | background-color: #272822; 43 | color: #f8f8f2; 44 | border: none; 45 | border-bottom: 1px solid #5b5c56; 46 | font-family: consolas, monospace; 47 | font-size: 16px; 48 | } 49 | 50 | .footer { 51 | font-size: small; 52 | text-align: center; 53 | color: #5b5c56; 54 | } 55 | 56 | .footer a { 57 | color: #5b5c56; 58 | } 59 | 60 | .button { 61 | background-color: #49483e; 62 | color: #f8f8f2; 63 | border-radius: 9px; 64 | cursor: pointer; 65 | padding: 2px 8px; 66 | font-weight: normal; 67 | } 68 | 69 | .button:hover { 70 | background-color: #5b5c56; 71 | } 72 | 73 | .menu-container { 74 | position: relative; 75 | } 76 | 77 | .menu { 78 | position: absolute; 79 | top: 100%; 80 | left: 0; 81 | z-index: 1000; 82 | float: left; 83 | min-width: 160px; 84 | border: 1px solid #5b5c56; 85 | background-color: #272822; 86 | } 87 | 88 | .menu-item { 89 | color: #fff; 90 | white-space: nowrap; 91 | cursor: pointer; 92 | padding: 5px 20px; 93 | } 94 | 95 | .menu-item:hover { 96 | background-color: #49483e; 97 | } 98 | 99 | .tab-bar { 100 | display: table; 101 | width: 100%; 102 | } 103 | 104 | .tabs { 105 | margin: 0; 106 | padding: 0; 107 | } 108 | 109 | .project-tabs { 110 | display: table-cell; 111 | vertical-align: bottom; 112 | } 113 | 114 | .app-tabs { 115 | display: table-cell; 116 | width: 1px; 117 | vertical-align: bottom; 118 | } 119 | 120 | .tabs li { 121 | margin: 0; 122 | padding: 8px 16px; 123 | list-style: none; 124 | display: inline-block; 125 | cursor: pointer; 126 | border-top-right-radius: 12px; 127 | border-top-left-radius: 12px; 128 | } 129 | 130 | .tabs li:hover { 131 | background-color: #49483e; 132 | } 133 | 134 | .tabs li.active { 135 | background-color: #5b5c56; 136 | cursor: default; 137 | } 138 | 139 | .panel { 140 | border: 1px solid #5b5c56; 141 | border-bottom-right-radius: 12px; 142 | border-bottom-left-radius: 12px; 143 | padding: 12px; 144 | } 145 | 146 | .panel p { 147 | margin-left: 12px; 148 | margin-right: 12px; 149 | } 150 | 151 | td.project-order, td.project-show, td.session-current { 152 | text-align: center; 153 | } 154 | 155 | .todo-item { 156 | display: table; 157 | width: 100%; 158 | padding: 6px 0; 159 | margin: 8px 0; 160 | } 161 | 162 | .todo-item:last-child { 163 | margin-bottom: 0; 164 | } 165 | 166 | .todo-item:hover { 167 | background-color: #49483e; 168 | } 169 | 170 | .dragging { 171 | opacity: 0.5; 172 | } 173 | 174 | .dropzone.dragover { 175 | outline: 1px dashed #5b5c56; 176 | } 177 | 178 | .doing-dropzone.empty { 179 | margin: 8px 0; 180 | padding: 6px 0; 181 | color: #5b5c56; 182 | text-align: center; 183 | } 184 | 185 | .todo-item .todo-item-toolbar { 186 | display: table-cell; 187 | height: 100%; 188 | width: 1px; 189 | padding: 0 12px 0 6px; 190 | white-space: pre; 191 | } 192 | 193 | .todo-item .todo-item-text { 194 | display: table-cell; 195 | height: 100%; 196 | } 197 | 198 | /* Hack for IE contentEditable difference - make p behave like br */ 199 | .todo-item .todo-item-text p { 200 | margin: 0; 201 | } 202 | 203 | .todo-item .todo-item-handle { 204 | display: table-cell; 205 | text-align: center; 206 | height: 100%; 207 | width: 1px; 208 | padding: 0 6px 0 12px; 209 | } 210 | 211 | .todo-item .todo-item-handle { 212 | visibility: hidden; 213 | } 214 | 215 | .todo-item:hover .todo-item-handle { 216 | visibility: visible; 217 | } 218 | 219 | .handle { 220 | cursor: move 221 | } 222 | 223 | .control { 224 | cursor: pointer; 225 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="utf-8"> 5 | <meta http-equiv="X-UA-Compatible" content="IE=edge"> 6 | <title>reactodo 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /reactodo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/reactodo/a44cee068eb2866697f866aff48467fef3ceffe2/reactodo.png -------------------------------------------------------------------------------- /src/Constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | LOCALSTORAGE_PREFIX: 'reactodo:' 5 | , ENTER_KEY: 13 6 | , ESCAPE_KEY: 27 7 | , NBSP: '\u00A0' 8 | , UP_ARROW: '\u2191' 9 | , DOWN_ARROW: '\u2193' 10 | , CHECK: 'X' 11 | , SETTINGS: '\u2605' 12 | , DRAG_HANDLE: '\u2630' 13 | , STOP: '\u25A0' 14 | , TRIANGLE_DOWN: '\u25BC' 15 | , TRIANGLE_UP: '\u25B2' 16 | } -------------------------------------------------------------------------------- /src/Page.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Page = { 4 | WELCOME: 1 5 | , SETTINGS: 2 6 | , TODO_LISTS: 3 7 | } 8 | 9 | module.exports = Page -------------------------------------------------------------------------------- /src/app.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react') 4 | 5 | var Reactodo = require('Reactodo') 6 | 7 | var trim = require('trim') 8 | 9 | var sessionMatch = /^\?(.+)/.exec(trim(decodeURIComponent(window.location.search))) 10 | var session = (sessionMatch != null ? sessionMatch[1] : '') 11 | 12 | React.render(, document.getElementById('reactodo')) -------------------------------------------------------------------------------- /src/components/Project.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react') 4 | 5 | var Constants = require('Constants') 6 | var TodoItem = require('TodoItem') 7 | 8 | var $c = require('classNames') 9 | 10 | var Project = React.createClass({ 11 | getInitialState() { 12 | return { 13 | dragoverTodoId: null 14 | , dragoverDoing: false 15 | } 16 | }, 17 | 18 | addTodo() { 19 | this.props.onAddTodo(this.props.project) 20 | }, 21 | 22 | deleteDoneTodos() { 23 | if (confirm('Are you sure you want to delete all completed TODOs in ' + this.props.project.name + '?')) { 24 | this.props.onDeleteDoneTodos(this.props.project) 25 | } 26 | }, 27 | 28 | /** Indicates that the [DOING] dropzone is a drop target. */ 29 | handleDragEnterDoing(e) { 30 | e.preventDefault() 31 | }, 32 | 33 | /** Sets the drop effect for the [DOING] dropzone. */ 34 | handleDragOverDoing(e) { 35 | e.preventDefault() 36 | e.dataTransfer.dropEffect = 'move' 37 | if (!this.state.dragoverDoing) { 38 | this.setState({ 39 | dragoverTodoId: null 40 | , dragoverDoing: true 41 | }) 42 | } 43 | }, 44 | 45 | /** Removes the drop effect for the [DOING] dropzone. */ 46 | handeDragLeaveDoing(e) { 47 | if (this.state.dragoverDoing) { 48 | this.setState({dragoverDoing: false}) 49 | } 50 | }, 51 | 52 | /** Handles a TODO being dropped on the [DOING] dropzone. */ 53 | handleDropDoing(e) { 54 | e.preventDefault() 55 | var index = Number(e.dataTransfer.getData('text')) 56 | if (this.state.dragoverDoing) { 57 | this.setState({ 58 | dragoverTodoId: null 59 | , dragoverDoing: false 60 | }) 61 | } 62 | this.props.onDoTodo(this.props.project, this.props.project.todos[index]) 63 | }, 64 | 65 | onToggleTodo(todo) { 66 | this.props.onToggleTodo(this.props.project, todo) 67 | }, 68 | 69 | onDoTodo(todo) { 70 | this.props.onDoTodo(this.props.project, todo) 71 | }, 72 | 73 | stopDoingTodo() { 74 | this.props.onStopDoingTodo(this.props.project) 75 | }, 76 | 77 | onEditTodo(todo, newText) { 78 | this.props.onEditTodo(this.props.project, todo, newText) 79 | }, 80 | 81 | onDeleteTodo(todo) { 82 | this.props.onDeleteTodo(this.props.project, todo) 83 | }, 84 | 85 | onDragOverTodo(todo) { 86 | if (this.state.dragoverTodoId != todo.id) { 87 | this.setState({dragoverTodoId: todo.id}) 88 | } 89 | }, 90 | 91 | onDragLeaveTodo() { 92 | this.setState({dragoverTodoId: null}) 93 | }, 94 | 95 | onDragEndTodo() { 96 | this.setState({ 97 | dragoverTodoId: null 98 | , dragoverDoing: false 99 | }) 100 | }, 101 | 102 | onMoveTodo(fromIndex, toIndex) { 103 | this.props.onMoveTodo(this.props.project, fromIndex, toIndex) 104 | }, 105 | 106 | render() { 107 | var doing, todos = [], dones = [] 108 | this.props.project.todos.forEach((todo, index) => { 109 | var currentlyDoing = (this.props.project.doing === todo.id) 110 | var todoItem = 126 | if (currentlyDoing) { 127 | doing = todoItem 128 | } 129 | else { 130 | ;(todo.done ? dones : todos).push(todoItem) 131 | } 132 | }) 133 | 134 | var doneHeading 135 | if (dones.length > 0) { 136 | doneHeading =

[DONE] -

137 | } 138 | 139 | var stopDoing 140 | if (doing) { 141 | stopDoing = {Constants.STOP} 142 | } 143 | 144 | return
145 |

[DOING] {stopDoing}

146 |
153 | {doing || "Drop a TODO here when it's in progress"} 154 |
155 |

[TODO] +

156 | {todos} 157 | {doneHeading} 158 | {dones} 159 |
160 | } 161 | }) 162 | 163 | module.exports = Project -------------------------------------------------------------------------------- /src/components/Reactodo.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react') 4 | 5 | var Page = require('Page') 6 | var Project = require('Project') 7 | var Settings = require('Settings') 8 | var Welcome = require('Welcome') 9 | 10 | var $c = require('classNames') 11 | var extend = require('extend') 12 | var partial = require('partial') 13 | var uuid = require('uuid') 14 | 15 | var {LOCALSTORAGE_PREFIX, SETTINGS, TRIANGLE_DOWN, TRIANGLE_UP} = require('Constants') 16 | 17 | var Reactodo = React.createClass({ 18 | getInitialState() { 19 | return this.getStateForSession(this.props.session) 20 | }, 21 | 22 | /** 23 | * Gets state for the named session, loading from localStorage if available. 24 | */ 25 | getStateForSession(session) { 26 | var storedJSON = localStorage[LOCALSTORAGE_PREFIX + session] 27 | var storedState = storedJSON ? JSON.parse(storedJSON) : {} 28 | var state = extend({ 29 | activeProjectId: null 30 | , editTodoId: null 31 | , projects: [] 32 | , showingSessionMenu: false 33 | }, storedState) 34 | state.page = 35 | (state.projects.length && state.activeProjectId !== null 36 | ? Page.TODO_LISTS 37 | : Page.WELCOME) 38 | return state 39 | }, 40 | 41 | componentDidMount() { 42 | this.updateWindowTitle() 43 | }, 44 | 45 | /** 46 | * Reloads state if the session name has changed. 47 | */ 48 | componentWillReceiveProps(nextProps) { 49 | if (this.props.session !== nextProps.session) { 50 | var sessionState = this.getStateForSession(nextProps.session) 51 | if (nextProps.keepPage) { 52 | // If the new session doesn't have any TODO lists, don't send it to the 53 | // TODO lists page, or the user will get an empty screen. If the new 54 | // session does have TODO lists and we're on the Welcome page, don't 55 | // stay on the Welcome page. 56 | if (!(sessionState.page === Page.WELCOME && this.state.page === Page.TODO_LISTS) && 57 | !(sessionState.page === Page.TODO_LISTS && this.state.page === Page.WELCOME)) { 58 | sessionState.page = this.state.page 59 | } 60 | } 61 | this.setState(sessionState) 62 | } 63 | }, 64 | 65 | /** 66 | * Stores session state when there's been a state change. Ensures the window 67 | * title is updated if the session changed as part of the update. 68 | */ 69 | componentDidUpdate(prevProps, prevState) { 70 | if (prevProps.session !== this.props.session) { 71 | this.updateWindowTitle() 72 | } 73 | localStorage[LOCALSTORAGE_PREFIX + this.props.session] = JSON.stringify({ 74 | activeProjectId: this.state.activeProjectId 75 | , projects: this.state.projects 76 | }) 77 | }, 78 | 79 | updateWindowTitle() { 80 | if (this.props.session) { 81 | document.title = this.props.session + ' - reactodo' 82 | } 83 | else { 84 | document.title = 'reactodo' 85 | } 86 | }, 87 | 88 | /** 89 | * Switches to another named session on the fly. If a keepPage option is 90 | * passed and is truthy, the current page state will be retained. 91 | */ 92 | switchSession(session, options) { 93 | options = extend({keepPage: false}, options) 94 | if (typeof history !== 'undefined' && history.replaceState) { 95 | history.replaceState(session, session + ' - reactodo', '?' + encodeURIComponent(session)) 96 | } 97 | this.setProps({session: session, keepPage: options.keepPage}) 98 | }, 99 | 100 | /** 101 | * Determines session names present in localStorage. 102 | */ 103 | getSessions() { 104 | return Object.keys(localStorage) 105 | .filter(function(p) { return p.indexOf(LOCALSTORAGE_PREFIX) === 0 }) 106 | .map(function(p) { return p.substring(LOCALSTORAGE_PREFIX.length) }) 107 | }, 108 | 109 | /** Adds a new session without switching to it. */ 110 | addSession(sessionName) { 111 | localStorage[LOCALSTORAGE_PREFIX + sessionName] = JSON.stringify({ 112 | activeProjectId: null 113 | , projects: [] 114 | }) 115 | this.forceUpdate() 116 | }, 117 | 118 | /** 119 | * Copies session state from one name to another and deletes the original. If 120 | * the original was the active session, switches to it. 121 | */ 122 | editSessionName(session, newName) { 123 | localStorage[LOCALSTORAGE_PREFIX + newName] = 124 | localStorage[LOCALSTORAGE_PREFIX + session] 125 | this.deleteSession(session) 126 | if (this.props.session === session) { 127 | this.switchSession(newName, {keepPage: true}) 128 | } 129 | }, 130 | 131 | deleteSession(sessionName) { 132 | delete localStorage[LOCALSTORAGE_PREFIX + sessionName] 133 | this.forceUpdate() 134 | }, 135 | 136 | setPage(page) { 137 | this.setState({page: page}) 138 | }, 139 | 140 | /** 141 | * Sets the given project id as active and switches to displaying its TODOs if 142 | * currently on another screen. 143 | */ 144 | setActiveProject(projectId) { 145 | this.setState({ 146 | activeProjectId: projectId 147 | , page: Page.TODO_LISTS 148 | }) 149 | }, 150 | 151 | addProject(projectName) { 152 | var id = uuid() 153 | this.state.projects.push({id: id, name: projectName, doing: null, todos: []}) 154 | this.setState({projects: this.state.projects}) 155 | }, 156 | 157 | editProjectName(project, projectName) { 158 | project.name = projectName 159 | this.setState({projects: this.state.projects}) 160 | }, 161 | 162 | moveProjectUp(project, index) { 163 | this.state.projects.splice(index - 1, 0, this.state.projects.splice(index, 1)[0]) 164 | this.setState({projects: this.state.projects}) 165 | }, 166 | 167 | moveProjectDown(project, index) { 168 | this.state.projects.splice(index + 1, 0, this.state.projects.splice(index, 1)[0]) 169 | this.setState({projects: this.state.projects}) 170 | }, 171 | 172 | toggleProjectVisible(project) { 173 | project.hidden = !project.hidden 174 | this.setState({projects: this.state.projects}) 175 | }, 176 | 177 | /** 178 | * Deletes a project and sets the next adjacent project as active if there are 179 | * any. 180 | */ 181 | deleteProject(project, index) { 182 | this.state.projects.splice(index, 1) 183 | var activeProjectId = this.state.activeProjectId 184 | if (this.state.projects.length === 0) { 185 | activeProjectId = null 186 | } 187 | else if (activeProjectId === project.id) { 188 | if (index <= this.state.projects.length - 1) { 189 | activeProjectId = this.state.projects[index].id 190 | } 191 | else { 192 | activeProjectId = this.state.projects[index - 1].id 193 | } 194 | } 195 | this.setState({ 196 | activeProjectId: activeProjectId 197 | , projects: this.state.projects 198 | }) 199 | }, 200 | 201 | addTodo(project) { 202 | var id = uuid() 203 | project.todos.unshift({id: id , done: false, text: ''}) 204 | this.setState({ 205 | editTodoId: id 206 | , projects: this.state.projects 207 | }) 208 | }, 209 | 210 | editTodo(project, todo, newText) { 211 | todo.text = newText 212 | this.setState({ 213 | editTodoId: null 214 | , projects: this.state.projects 215 | }) 216 | }, 217 | 218 | moveTodo(project, fromIndex, toIndex) { 219 | var fromTodo = project.todos[fromIndex] 220 | , toTodo = project.todos[toIndex] 221 | if (fromTodo.done !== toTodo.done) { 222 | fromTodo.done = toTodo.done 223 | } 224 | project.todos.splice(toIndex, 0, project.todos.splice(fromIndex, 1)[0]) 225 | this.setState({projects: this.state.projects}) 226 | }, 227 | 228 | toggleTodo(project, todo) { 229 | todo.done = !todo.done 230 | if (project.doing === todo.id) { 231 | project.doing = null 232 | } 233 | this.setState({projects: this.state.projects}) 234 | }, 235 | 236 | doTodo(project, todo) { 237 | project.doing = todo.id 238 | if (todo.done) { 239 | todo.done = false 240 | } 241 | this.setState({projects: this.state.projects}) 242 | }, 243 | 244 | stopDoingTodo(project) { 245 | project.doing = null 246 | this.setState({projects: this.state.projects}) 247 | }, 248 | 249 | deleteTodo(project, todo) { 250 | for (var i = 0, l = project.todos.length; i < l; i++) { 251 | if (project.todos[i].id === todo.id) { 252 | project.todos.splice(i, 1) 253 | if (project.doing === todo.id) { 254 | project.doing = null 255 | } 256 | return this.setState({projects: this.state.projects}) 257 | } 258 | } 259 | }, 260 | 261 | deleteDoneTodos(project) { 262 | project.todos = project.todos.filter(function(todo) { return !todo.done }) 263 | this.setState({projects: this.state.projects}) 264 | }, 265 | 266 | showSessionMenu() { 267 | this.setState({showingSessionMenu: true}) 268 | }, 269 | 270 | pickSession(session, e) { 271 | e.preventDefault() 272 | e.stopPropagation() 273 | this.hideSessionMenu() 274 | this.switchSession(session, {keepPage: true}) 275 | }, 276 | 277 | hideSessionMenu() { 278 | this.setState({showingSessionMenu: false}) 279 | }, 280 | 281 | render() { 282 | var tabs = [] 283 | var content 284 | 285 | if (this.state.page === Page.WELCOME) { 286 | content = 292 | } 293 | else if (this.state.page === Page.SETTINGS) { 294 | content = 309 | } 310 | 311 | // Always display project tabs when available 312 | this.state.projects.forEach(project => { 313 | if (project.hidden) { return } 314 | var isActiveProject = (this.state.page === Page.TODO_LISTS && 315 | this.state.activeProjectId === project.id) 316 | tabs.push(
  • 319 | {project.name} 320 |
  • ) 321 | if (isActiveProject) { 322 | content = 334 | } 335 | }) 336 | 337 | // Ensure there's something in tabs so its display isn't collapsed (Chrome) 338 | if (!tabs.length) { tabs.push(' ') } 339 | 340 | var sessions = this.getSessions() 341 | var multipleSessions = sessions.length > 1 342 | var activeSession 343 | var sessionMenu 344 | if (!this.state.showingSessionMenu) { 345 | var sessionName = this.props.session 346 | if (multipleSessions) { 347 | sessionName += ' ' + TRIANGLE_DOWN 348 | } 349 | activeSession = 350 | {sessionName} 351 | 352 | } 353 | else { 354 | activeSession = 355 | {this.props.session + ' ' + TRIANGLE_UP} 356 | 357 | 358 | sessions.sort() 359 | sessions.splice(sessions.indexOf(this.props.session), 1) 360 | 361 | var menuItems = sessions.map((session, index) => { 362 | var sessionName = session || '(Default)' 363 | return
    364 | {sessionName} 365 |
    366 | }) 367 | 368 | sessionMenu =
    {menuItems}
    369 | } 370 | 371 | return
    372 |

    373 | reactodo{' '} 374 | {activeSession}{sessionMenu} 375 |

    376 |
    377 |
      {tabs}
    378 |
      379 |
    • {SETTINGS}
    • 384 |
    385 |
    386 |
    387 | {content} 388 |
    389 |
    390 | } 391 | }) 392 | 393 | module.exports = Reactodo -------------------------------------------------------------------------------- /src/components/Settings.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react') 4 | 5 | var EditInput = require('EditInput') 6 | 7 | var exportProject = require('exportProject') 8 | var exportTextFile = require('exportTextFile') 9 | var partial = require('partial') 10 | 11 | var {CHECK, DOWN_ARROW, NBSP, UP_ARROW} = require('Constants') 12 | 13 | var Settings = React.createClass({ 14 | getInitialState() { 15 | return { 16 | addingProject: false 17 | , addingSession: false 18 | , editingProjectName: null 19 | , editingSessionName: null 20 | } 21 | }, 22 | 23 | addProject(projectName) { 24 | if (!this.state.addingProject) { 25 | this.setState({addingProject: true}) 26 | } 27 | else { 28 | this.setState({addingProject: false}) 29 | this.props.onAddProject(projectName) 30 | } 31 | }, 32 | 33 | cancelAddProject() { 34 | this.setState({addingProject: false}) 35 | }, 36 | 37 | editProjectName(project, projectName) { 38 | if (this.state.editingProjectName !== project.id) { 39 | this.setState({editingProjectName: project.id}) 40 | } 41 | else { 42 | this.setState({editingProjectName: null}) 43 | this.props.onEditProjectName(project, projectName) 44 | } 45 | }, 46 | 47 | cancelEditProjectName() { 48 | this.setState({editingProjectName: null}) 49 | }, 50 | 51 | deleteProject(project, index) { 52 | if (confirm('Are you sure you want to delete "' + project.name + '"?')) { 53 | this.props.onDeleteProject(project, index) 54 | } 55 | }, 56 | 57 | sessionNameAlreadyExists(name) { 58 | return (this.props.getSessions().indexOf(name) != -1) 59 | }, 60 | 61 | addSession(sessionName) { 62 | if (!this.state.addingSession) { 63 | this.setState({addingSession: true}) 64 | } 65 | else { 66 | if (this.sessionNameAlreadyExists(sessionName)) { 67 | return alert('A session named "' + sessionName + '" already exists.') 68 | } 69 | this.setState({addingSession: false}) 70 | this.props.onAddSession(sessionName) 71 | } 72 | }, 73 | 74 | cancelAddSession() { 75 | this.setState({addingSession: false}) 76 | }, 77 | 78 | editSessionName(session, newName) { 79 | if (this.state.editingSessionName !== session) { 80 | this.setState({editingSessionName: session}) 81 | } 82 | else { 83 | if (this.sessionNameAlreadyExists(newName) && newName !== session) { 84 | return alert('A session named "' + newName + '" already exists.') 85 | } 86 | this.setState({editingSessionName: null}) 87 | if (newName !== session) { 88 | this.props.onEditSessionName(session, newName) 89 | } 90 | } 91 | }, 92 | 93 | cancelEditSessionName() { 94 | this.setState({editingSessionName: null}) 95 | }, 96 | 97 | deleteSession(session) { 98 | if (confirm('Are you sure you want to delete "' + session + 99 | '"? All Projects and TODOs will be lost.')) { 100 | this.props.onDeleteSession(session) 101 | } 102 | this.forceUpdate() 103 | }, 104 | 105 | toggleProjectVisible(project) { 106 | this.props.onToggleProjectVisible(project) 107 | }, 108 | 109 | exportProject(project) { 110 | exportTextFile(exportProject(project), project.name + '.todo.txt') 111 | }, 112 | 113 | render() { 114 | var addProject 115 | if (this.state.addingProject) { 116 | addProject = 121 | } 122 | else { 123 | addProject = + 124 | } 125 | 126 | var projects 127 | if (this.props.projects.length) { 128 | projects =
    129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | {this.props.projects.map(this.renderProject)} 141 | 142 |
    NameOrderShow?{NBSP}{NBSP}
    143 |

    Click on a project's name to edit it.

    144 |
    145 | } 146 | 147 | var addSession 148 | if (this.state.addingSession) { 149 | addSession = 154 | } 155 | else { 156 | addSession = + 157 | } 158 | 159 | var sessions =
    160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | {this.props.getSessions().map(this.renderSession)} 171 | 172 |
    NameCurrent?{NBSP}{NBSP}
    173 |

    Click on a session's name to edit it.

    174 |
    175 | 176 | return
    177 |

    [PROJECTS] {addProject}

    178 | {projects} 179 |

    [SESSIONS] {addSession}

    180 | {sessions} 181 |
    182 | }, 183 | 184 | renderProject(project, i, projects) { 185 | var first = (i === 0) 186 | var last = (i == projects.length - 1) 187 | var up = (first ? {NBSP} : 188 | 189 | {UP_ARROW} 190 | 191 | ) 192 | var down = (last ? {NBSP} : 193 | 194 | {DOWN_ARROW} 195 | 196 | ) 197 | var projectName 198 | if (this.state.editingProjectName === project.id) { 199 | projectName = 205 | } 206 | else { 207 | projectName = 208 | {project.name} 209 | 210 | } 211 | 212 | return 213 | {projectName} 214 | {up}{NBSP}{down} 215 | 216 | 217 | [{project.hidden ? NBSP : CHECK}] 218 | 219 | 220 | 221 | Export 222 | 223 | 224 | Delete 225 | 226 | 227 | }, 228 | 229 | renderSession(session) { 230 | var displayName = (session === '' ? '(Default)' : session) 231 | var isActiveSession = (session === this.props.session) 232 | var sessionName 233 | if (this.state.editingSessionName === session) { 234 | sessionName = 240 | } 241 | else { 242 | sessionName = 243 | {displayName} 244 | 245 | } 246 | var switchSession = NBSP 247 | var deleteSession = NBSP 248 | if (!isActiveSession) { 249 | switchSession = 250 | Switch 251 | 252 | deleteSession = 253 | Delete 254 | 255 | } 256 | 257 | return 258 | {sessionName} 259 | {isActiveSession ? '[' + CHECK + ']' : NBSP} 260 | {switchSession} 261 | {deleteSession} 262 | 263 | } 264 | }) 265 | 266 | module.exports = Settings -------------------------------------------------------------------------------- /src/components/TodoItem.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react') 4 | 5 | var $c = require('classNames') 6 | var normaliseContentEditableHTML = require('normaliseContentEditableHTML') 7 | var partial = require('partial') 8 | 9 | var {CHECK, DRAG_HANDLE, NBSP} = require('Constants') 10 | 11 | var TodoItem = React.createClass({ 12 | getInitialState() { 13 | return { 14 | dragging: false 15 | , editing: this.props.initialEdit || false 16 | } 17 | }, 18 | 19 | componentDidMount() { 20 | if (this.props.initialEdit) { 21 | this.refs.text.getDOMNode().focus() 22 | } 23 | }, 24 | 25 | componentDidUpdate (prevProps, prevState) { 26 | if (this.state.editing && !prevState.editing) { 27 | this.refs.text.getDOMNode().focus() 28 | } 29 | }, 30 | 31 | handleTextClick() { 32 | if (!this.state.editing) { 33 | this.setState({editing: true}) 34 | } 35 | }, 36 | 37 | handleTextBlur() { 38 | if (this.state.editing) { 39 | var text = normaliseContentEditableHTML(this.refs.text.getDOMNode().innerHTML) 40 | // Re-apply normalised HTML to the TODO's text 41 | if (text) { 42 | this.refs.text.getDOMNode().innerHTML = text 43 | } 44 | this.setState({editing: false}) 45 | if (!text) { 46 | this.props.onDelete(this.props.todo) 47 | } 48 | else { 49 | this.props.onEdit(this.props.todo, text) 50 | } 51 | } 52 | }, 53 | 54 | handleDragStart(e) { 55 | e.dataTransfer.setData('text', '' + this.props.index) 56 | this.setState({dragging: true}) 57 | }, 58 | 59 | handleDragEnd(e) { 60 | this.setState({dragging: false}) 61 | this.props.onDragEnd() 62 | }, 63 | 64 | /** Indicates that this TODO is a drop target. */ 65 | handleDragEnter(e) { 66 | e.preventDefault() 67 | }, 68 | 69 | /** Sets the drop effect for this TODO. */ 70 | handleDragOver(e) { 71 | if (this.state.dragging) { 72 | return 73 | } 74 | e.preventDefault() 75 | e.dataTransfer.dropEffect = 'move' 76 | this.props.onDragOver(this.props.todo) 77 | }, 78 | 79 | /** Handles another TODO being dropped on this one. */ 80 | handleDrop(e) { 81 | e.preventDefault() 82 | var fromIndex = Number(e.dataTransfer.getData('text')) 83 | this.props.onMoveTodo(fromIndex, this.props.index) 84 | }, 85 | 86 | /** 87 | * IE9 doesn't support draggable="true" on s. This hack manually starts 88 | * the drag & drop process onMouseDown. The setTimeout not only bothers me but 89 | * doesn't always seem to work - without it, the classes which set style for 90 | * the item being dragged and dropzones being dragged over aren't applied. 91 | */ 92 | handleIE9DragHack(e) { 93 | e.preventDefault() 94 | if (window.event.button === 1) { 95 | var target = e.nativeEvent.target 96 | setTimeout(function() { target.dragDrop() }, 50) 97 | } 98 | }, 99 | 100 | render() { 101 | var todoItemClassName = $c('todo-item', { 102 | 'is-todo': !this.props.todo.done 103 | , 'is-done': this.props.todo.done 104 | , 'is-doing': this.props.doing 105 | , 'dropzone': !this.props.doing 106 | , 'dragging': this.state.dragging 107 | , 'dragover': this.props.dragover 108 | }) 109 | 110 | var dragHandle 111 | if (!this.props.doing) { 112 | dragHandle =
    113 | {DRAG_HANDLE} 120 |
    121 | } 122 | 123 | // onDrop is handled by the [DOING] dropZone if that's where this TODO is 124 | // being displayed. 125 | return
    132 |
    133 | [{this.props.todo.done ? CHECK : NBSP}] 134 |
    135 |
    143 | {dragHandle} 144 |
    145 | } 146 | }) 147 | 148 | module.exports = TodoItem -------------------------------------------------------------------------------- /src/components/Welcome.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react') 4 | 5 | var trim = require('trim') 6 | 7 | var {DRAG_HANDLE, ENTER_KEY, SETTINGS, STOP} = require('Constants') 8 | 9 | var Welcome = React.createClass({ 10 | switchSession() { 11 | var sessionName = trim(this.refs.sessionName.getDOMNode().value) 12 | if (sessionName) { 13 | this.props.onSwitchSession(sessionName) 14 | } 15 | }, 16 | 17 | onSessionNameKeyDown(e) { 18 | if (e.which === ENTER_KEY) { 19 | this.switchSession() 20 | } 21 | }, 22 | 23 | render() { 24 | var currentSession 25 | if (!this.props.session) { 26 | currentSession =
    27 |

    You are currently using the default session.

    28 |

    29 | If you'd like to switch to a named session which describes your TODOs 30 | (e.g. "work projects" or "personal projects"), enter the name you'd 31 | like to use below: 32 |

    33 |
    34 | } 35 | else { 36 | currentSession =
    37 |

    38 | You are currently using a named session - "{this.props.session}" - 39 | bookmark the current address to easily return to this session again 40 | later. 41 |

    42 |

    43 | If you'd like to switch to another named session enter a name below: 44 |

    45 |
    46 | } 47 | 48 | var sessions = this.props.getSessions().filter(function(session) { 49 | return session !== '' 50 | }) 51 | sessions.sort() 52 | var datalistOptions = sessions.map(function(session) { 53 | return