blob: d966443e178790358f44964006100b3896260323 [file] [log] [blame]
var app = app || {};
(function() {
'use strict';
////////////////////////////////////////
// Helpers
var d = new app.Dispatcher();
var activateInput = function(input) {
input.focus();
input.select();
};
var okCancelEvents = function(callbacks) {
var ok = callbacks.ok || function() {};
var cancel = callbacks.cancel || function() {};
var done = function(ev) {
var value = ev.target.value;
if (value) {
ok(value, ev);
} else {
cancel(ev);
}
};
return {
onKeyDown: function(ev) {
if (ev.which === 27) { // esc
cancel(ev);
}
},
onKeyUp: function(ev) {
if (ev.which === 13) { // enter
done(ev);
}
},
onBlur: function(ev) {
done(ev);
}
};
};
////////////////////////////////////////
// Components
var TagFilter = React.createClass({
displayName: 'TagFilter',
render: function() {
var that = this;
var tagFilter = this.props.tagFilter;
var tagInfos = [], totalCount = 0;
_.each(this.props.todos, function(todo) {
_.each(todo.tags, function(tag) {
var tagInfo = _.find(tagInfos, function(x) {
return x.tag === tag;
});
if (!tagInfo) {
tagInfos.push({tag: tag, count: 1, selected: tagFilter === tag});
} else {
tagInfo.count++;
}
});
totalCount++;
});
tagInfos = _.sortBy(tagInfos, function(x) { return x.tag; });
// Note, the 'All items' tag handling is fairly convoluted in Meteor.
tagInfos.unshift({
tag: null,
count: totalCount,
selected: tagFilter === null
});
var children = [];
_.each(tagInfos, function(tagInfo) {
var count = React.DOM.span(
{className: 'count'}, '(' + tagInfo.count + ')');
children.push(React.DOM.div({
className: 'tag' + (tagInfo.selected ? ' selected' : ''),
onMouseDown: function() {
var newTagFilter = tagFilter === tagInfo.tag ? null : tagInfo.tag;
that.props.setTagFilter(newTagFilter);
}
}, tagInfo.tag === null ? 'All items' : tagInfo.tag, ' ', count));
});
return React.DOM.div(
{id: 'tag-filter', className: 'tag-list'},
React.DOM.div({className: 'label'}, 'Show:'),
children);
}
});
var Tags = React.createClass({
displayName: 'Tags',
getInitialState: function() {
return {
addingTag: false
};
},
componentDidUpdate: function() {
if (this.state.addingTag) {
activateInput(this.getDOMNode().querySelector('#edittag-input'));
}
},
render: function() {
var that = this;
var children = [];
_.each(this.props.tags, function(tag) {
// Note, we must specify the "key" prop so that React doesn't reuse the
// opacity=0 node after a tag is removed.
children.push(React.DOM.div(
{className: 'tag removable_tag', key: tag},
React.DOM.div({className: 'name'}, tag),
React.DOM.div({
className: 'remove',
onClick: function(ev) {
ev.target.parentNode.style.opacity = 0;
// Wait for CSS animation to finish.
window.setTimeout(function() {
d.removeTag(that.props.todoId, tag);
}, 300);
}
})));
});
if (this.state.addingTag) {
children.push(React.DOM.div(
{className: 'tag edittag'},
React.DOM.input(_.assign({
type: 'text',
id: 'edittag-input',
defaultValue: ''
}, okCancelEvents({
ok: function(value) {
d.addTag(that.props.todoId, value);
that.setState({addingTag: false});
},
cancel: function() {
that.setState({addingTag: false});
}
})))));
} else {
children.push(React.DOM.div({
className: 'tag addtag',
onClick: function() {
that.setState({addingTag: true});
}
}, '+tag'));
}
return React.DOM.div({className: 'item-tags'}, children);
}
});
var Todo = React.createClass({
displayName: 'Todo',
getInitialState: function() {
return {
editingText: false
};
},
componentDidUpdate: function() {
if (this.state.editingText) {
activateInput(this.getDOMNode().querySelector('#todo-input'));
}
},
render: function() {
var that = this;
var todo = this.props.todo, children = [];
if (this.state.editingText) {
children.push(React.DOM.div(
{className: 'edit'},
React.DOM.input(_.assign({
id: 'todo-input',
type: 'text',
defaultValue: todo.text
}, okCancelEvents({
ok: function(value) {
d.editTodoText(todo._id, value);
that.setState({editingText: false});
},
cancel: function() {
that.setState({editingText: false});
}
})))));
} else {
children.push(React.DOM.div({
className: 'destroy',
onClick: function() {
d.removeTodo(todo._id);
}
}));
children.push(React.DOM.div(
{className: 'display'},
React.DOM.input({
className: 'check',
name: 'markdone',
type: 'checkbox',
checked: todo.done,
onClick: function() {
d.markTodoDone(!todo.done);
}
}),
React.DOM.div({
className: 'todo-text',
onDoubleClick: function() {
that.setState({editingText: true});
}
}, todo.text)));
}
children.push(new Tags({todoId: todo._id, tags: todo.tags}));
return React.DOM.li({
className: 'todo' + (todo.done ? ' done' : '')
}, children);
}
});
var Todos = React.createClass({
displayName: 'Todos',
render: function() {
var that = this;
if (this.props.listId === null) {
return null;
}
var children = [];
if (this.props.todos === null) {
children.push('Loading...');
} else {
var tagFilter = this.props.tagFilter, items = [];
_.each(this.props.todos, function(todo) {
if (tagFilter === null || _.contains(todo.tags, tagFilter)) {
items.push(new Todo({todo: todo}));
}
});
children.push(React.DOM.div(
{id: 'new-todo-box'},
React.DOM.input(_.assign({
type: 'text',
id: 'new-todo',
placeholder: 'New item'
}, okCancelEvents({
ok: function(value, ev) {
var tags = tagFilter ? [tagFilter] : [];
d.addTodo(that.props.listId, value, tags);
ev.target.value = '';
}
})))));
children.push(React.DOM.ul({id: 'item-list'}, items));
}
return React.DOM.div({id: 'items-view'}, children);
}
});
var List = React.createClass({
displayName: 'List',
getInitialState: function() {
return {
editingName: false
};
},
componentDidUpdate: function() {
if (this.state.editingName) {
activateInput(this.getDOMNode().querySelector('#list-name-input'));
}
},
render: function() {
var that = this;
var list = this.props.list, child;
// http://facebook.github.io/react/docs/forms.html#controlled-components
if (this.state.editingName) {
child = React.DOM.div(
{className: 'edit'},
React.DOM.input(_.assign({
className: 'list-name-input',
id: 'list-name-input',
type: 'text',
defaultValue: list.name
}, okCancelEvents({
ok: function(value) {
d.editListName(list._id, value);
that.setState({editingName: false});
},
cancel: function() {
that.setState({editingName: false});
}
}))));
} else {
child = React.DOM.div(
{className: 'display'},
React.DOM.a({
className: 'list-name' + (list.name ? '' : ' empty'),
href: '/' + list._id
}, list.name));
}
return React.DOM.div({
className: 'list' + (list.selected ? ' selected' : ''),
onMouseDown: function() {
that.props.setListId(list._id);
},
onClick: function(ev) {
ev.preventDefault(); // prevent page refresh
},
onDoubleClick: function() {
that.setState({editingName: true});
}
}, child);
}
});
var Lists = React.createClass({
displayName: 'Lists',
render: function() {
var that = this;
var children = [React.DOM.h3({}, 'Todo Lists')];
if (this.props.lists === null) {
children.push(React.DOM.div({id: 'lists'}, 'Loading...'));
} else {
var lists = [];
_.each(this.props.lists, function(list) {
list.selected = that.props.listId === list._id;
lists.push(new List({
list: list,
setListId: that.props.setListId
}));
});
children.push(React.DOM.div({id: 'lists'}, lists));
children.push(React.DOM.div(
{id: 'createList'},
React.DOM.input(_.assign({
type: 'text',
id: 'new-list',
placeholder: 'New list'
}, okCancelEvents({
ok: function(value, ev) {
var id = d.addList(value);
that.props.setListId(id);
ev.target.value = '';
}
})))));
}
return React.DOM.div({}, children);
}
});
var Page = React.createClass({
displayName: 'Page',
getInitialState: function() {
return {
lists: null, // all lists
todos: null, // all todos for current listId
listId: this.props.initialListId, // current list
tagFilter: null // current tag
};
},
fetchLists: function() {
return app.Lists.find({}, {sort: {name: 1}});
},
fetchTodos: function(listId) {
if (listId === null) {
return null;
}
return app.Todos.find({listId: listId}, {sort: {timestamp: 1}});
},
updateURL: function() {
var router = this.props.router, listId = this.state.listId;
router.navigate(listId === null ? '' : String(listId));
},
componentDidMount: function() {
var that = this;
var lists = this.fetchLists();
var listId = this.state.listId;
if (listId === null && lists.length > 0) {
listId = lists[0]._id;
}
this.setState({
lists: lists,
todos: this.fetchTodos(listId),
listId: listId
});
this.updateURL();
app.Lists.onChange(function() {
that.setState({lists: that.fetchLists()});
});
app.Todos.onChange(function() {
that.setState({todos: that.fetchTodos(that.state.listId)});
});
},
componentDidUpdate: function() {
this.updateURL();
},
render: function() {
var that = this;
return React.DOM.div({}, [
React.DOM.div({id: 'top-tag-filter'}, new TagFilter({
todos: this.state.todos,
tagFilter: this.state.tagFilter,
setTagFilter: function(tagFilter) {
that.setState({tagFilter: tagFilter});
}
})),
React.DOM.div({id: 'main-pane'}, new Todos({
todos: this.state.todos,
listId: this.state.listId,
tagFilter: this.state.tagFilter
})),
React.DOM.div({id: 'side-pane'}, new Lists({
lists: this.state.lists,
listId: this.state.listId,
setListId: function(listId) {
if (listId !== that.state.listId) {
that.setState({
todos: that.fetchTodos(listId),
listId: listId
});
}
}
}))
]);
}
});
////////////////////////////////////////
// Initialization
app.init = function() {
var Router = Backbone.Router.extend({
routes: {
'': 'main',
':listId': 'main'
}
});
var router = new Router();
var page;
router.on('route:main', function(listId) {
console.assert(!page);
if (listId !== null) {
listId = Number(listId);
}
page = new Page({router: router, initialListId: listId});
React.renderComponent(page, document.getElementById('c'));
});
Backbone.history.start({pushState: true});
};
}());