| 'use strict'; |
| |
| /* jshint newcap: false */ |
| /* global Mousetrap */ |
| |
| var _ = require('lodash'); |
| var async = require('async'); |
| var page = require('page'); |
| var React = require('react'); |
| var url = require('url'); |
| var vanadium = require('vanadium'); |
| |
| var bm = require('./benchmark'); |
| var defaults = require('./defaults'); |
| var domLog = require('./dom_log'); |
| var util = require('./util'); |
| var h = util.h; |
| |
| //////////////////////////////////////// |
| // Constants |
| |
| var DISP_TYPE_COLLECTION = 'mem-collection'; |
| var DISP_TYPE_SYNCBASE = 'syncbase'; |
| |
| //////////////////////////////////////// |
| // Global state |
| |
| // Dispatcher and user's email address, both initialized by initDispatcher. |
| var disp, userEmail; |
| |
| // Used for query params. |
| var u = url.parse(window.location.href, true); |
| |
| // Mount table name. |
| var mtName = u.query.mt || (function() { |
| var loc = window.location; |
| return '/' + loc.hostname + ':' + (Number(loc.port) + 1); |
| }()); |
| |
| // See TODO in SyncbaseDispatcher.listIdToSgName to understand why we use an |
| // absolute name here. |
| var syncbaseName = u.query.sb || (mtName + '/syncbase'); |
| |
| //////////////////////////////////////// |
| // Helpers |
| |
| function noop() {} |
| |
| function initVanadium(cb) { |
| cb = util.logFn('initVanadium', cb); |
| var vanadiumConfig = { |
| logLevel: vanadium.vlog.levels.INFO, |
| namespaceRoots: [mtName], |
| proxy: u.query.proxy |
| }; |
| vanadium.init(vanadiumConfig, cb); |
| } |
| |
| function initDispatcher(dispType, syncbaseName, cb) { |
| var clientCb = cb; |
| cb = function(err, resDisp) { |
| if (err) return clientCb(err); |
| disp = resDisp; |
| clientCb(); |
| }; |
| if (dispType === DISP_TYPE_COLLECTION) { |
| defaults.initCollectionDispatcher(cb); |
| } else if (dispType === DISP_TYPE_SYNCBASE) { |
| initVanadium(function(err, rt) { |
| if (err) return cb(err); |
| userEmail = blessingToEmail(rt.accountName); |
| defaults.initSyncbaseDispatcher(rt, syncbaseName, cb); |
| }); |
| } else { |
| process.nextTick(function() { |
| cb(new Error('unknown dispType: ' + dispType)); |
| }); |
| } |
| } |
| |
| function alertOnError(err) { |
| if (!err) return; |
| alert(err); |
| throw err; |
| } |
| |
| // HACKETY HACK |
| function emailToBlessing(email) { |
| return 'dev.v.io/u/' + email; |
| } |
| function blessingToEmail(blessing) { |
| var parts = blessing.split('/'); |
| console.assert(parts.length >= 3); |
| return parts[2]; |
| } |
| |
| function activateInput(input) { |
| input.focus(); |
| input.select(); |
| } |
| |
| function okCancelEvents(cbs, cancelOnBlur) { |
| var ok = cbs.ok || noop; |
| var cancel = cbs.cancel || noop; |
| function done(e) { |
| var value = e.target.value; |
| if (value) { |
| ok(value, e); |
| } else { |
| cancel(e); |
| } |
| } |
| return { |
| onKeyDown: function(e) { |
| if (e.which === 27) { // esc |
| cancel(e); |
| } |
| }, |
| onKeyUp: function(e) { |
| if (e.which === 13) { // enter |
| done(e); |
| } |
| }, |
| onBlur: function(e) { |
| if (cancelOnBlur) { |
| cancel(e); |
| } else { |
| done(e); |
| } |
| } |
| }; |
| } |
| |
| //////////////////////////////////////// |
| // Components |
| |
| var TagsPane = React.createFactory(React.createClass({ |
| displayName: 'TagsPane', |
| 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, {tag: tag}); |
| if (!tagInfo) { |
| tagInfos.push({tag: tag, count: 1, selected: tagFilter === tag}); |
| } else { |
| tagInfo.count++; |
| } |
| }); |
| totalCount++; |
| }); |
| tagInfos = _.sortBy(tagInfos, 'tag'); |
| // Add "All items" tag. |
| tagInfos.unshift({ |
| tag: null, |
| count: totalCount, |
| selected: tagFilter === null |
| }); |
| return h('div#tags-pane', [ |
| h('div.label', {key: 'label'}, 'Show:') |
| ].concat(_.map(tagInfos, function(tagInfo) { |
| var count = h('span.count', {key: 'count'}, '(' + tagInfo.count + ')'); |
| return h('div.tag' + (tagInfo.selected ? '.selected' : ''), { |
| key: 'tag:' + (tagInfo.tag || ''), |
| onClick: function() { |
| var newTagFilter = tagFilter === tagInfo.tag ? null : tagInfo.tag; |
| that.props.setTagFilter(newTagFilter); |
| } |
| }, [(tagInfo.tag || 'All items'), count]); |
| }))); |
| } |
| })); |
| |
| var TodoTags = React.createFactory(React.createClass({ |
| displayName: 'TodoTags', |
| 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) { |
| children.push(h('div.tag.removable', {key: tag}, [ |
| h('div.name', {key: 'name'}, tag), |
| h('div.remove', { |
| key: 'remove', |
| onClick: function(e) { |
| disp.removeTag(that.props.todoId, tag, alertOnError); |
| } |
| }) |
| ])); |
| }); |
| if (this.state.addingTag) { |
| children.push(h('div.tag.edittag', { |
| key: 'edittag' |
| }, h('input#edittag-input', _.assign({ |
| type: 'text', |
| defaultValue: '' |
| }, okCancelEvents({ |
| ok: function(value) { |
| disp.addTag(that.props.todoId, value, alertOnError); |
| that.setState({addingTag: false}); |
| }, |
| cancel: function() { |
| that.setState({addingTag: false}); |
| } |
| }))))); |
| } else { |
| children.push(h('div.tag.addtag', { |
| key: 'addtag', |
| onClick: function() { |
| that.setState({addingTag: true}); |
| } |
| }, '+tag')); |
| } |
| return h('div.item-tags', children); |
| } |
| })); |
| |
| var Todo = React.createFactory(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, todo = this.props.todo, et = this.state.editingText; |
| var hDescription; |
| if (et) { |
| hDescription = h('div.description', { |
| key: 'description' |
| }, h('input#todo-input', _.assign({ |
| type: 'text', |
| defaultValue: todo.text |
| }, okCancelEvents({ |
| ok: function(value) { |
| disp.editTodoText(todo._id, value, alertOnError); |
| that.setState({editingText: false}); |
| }, |
| cancel: function() { |
| that.setState({editingText: false}); |
| } |
| })))); |
| } else { |
| hDescription = h('div.description', { |
| key: 'description', |
| onDoubleClick: function() { |
| that.setState({editingText: true}); |
| } |
| }, todo.text); |
| } |
| var opts = (et ? '.edit' : '') + (todo.done ? '.done' : ''); |
| return h('li.todo-row' + opts, [ |
| h('div.destroy', { |
| key: 'destroy', |
| onClick: function() { |
| disp.removeTodo(todo._id, alertOnError); |
| } |
| }), |
| h('input.checkbox', { |
| key: 'checkbox', |
| type: 'checkbox', |
| checked: todo.done, |
| onChange: function() { |
| disp.markTodoDone(todo._id, !todo.done, alertOnError); |
| } |
| }), |
| hDescription, |
| TodoTags({key: 'tags', todoId: todo._id, tags: todo.tags}) |
| ]); |
| } |
| })); |
| |
| var TodosPane = React.createFactory(React.createClass({ |
| displayName: 'TodosPane', |
| render: function() { |
| var that = this; |
| var children = []; |
| // TODO(sadovsky): If there are no lists (and thus no todos), we end up |
| // rendering "Loading..." here, which is wrong. |
| if (!this.props.listId || !this.props.todos) { |
| children.push(h('div.loading', {key: 'loading'}, 'Loading...')); |
| } else { |
| var tagFilter = this.props.tagFilter, items = []; |
| _.each(this.props.todos, function(todo) { |
| if (tagFilter === null || _.contains(todo.tags, tagFilter)) { |
| items.push(Todo({key: todo._id, todo: todo})); |
| } |
| }); |
| children.push(h('div#new-todo', {key: 'new-todo'}, h('input', _.assign({ |
| type: 'text', |
| placeholder: 'New item' |
| }, okCancelEvents({ |
| ok: function(value, e) { |
| disp.addTodo(that.props.listId, { |
| text: value, |
| tags: tagFilter ? [tagFilter] : [], |
| done: false, |
| timestamp: Date.now() |
| }, alertOnError); |
| e.target.value = ''; |
| } |
| }))))); |
| children.push(h('ul#todo-list', {key: 'todo-list'}, items)); |
| } |
| return h('div#todos-pane', children); |
| } |
| })); |
| |
| var List = React.createFactory(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, list = this.props.list; |
| var children = []; |
| // http://facebook.github.io/react/docs/forms.html#controlled-components |
| if (this.state.editingName) { |
| children.push(h('div.edit', { |
| key: 'edit' |
| }, h('input#list-name-input', _.assign({ |
| type: 'text', |
| defaultValue: list.name |
| }, okCancelEvents({ |
| ok: function(value) { |
| disp.editListName(list._id, value, alertOnError); |
| that.setState({editingName: false}); |
| }, |
| cancel: function() { |
| that.setState({editingName: false}); |
| } |
| }))))); |
| } else { |
| if (this.props.useSyncbase) { |
| children.push(h('div.status', { |
| key: 'status', |
| onClick: function(e) { |
| e.stopPropagation(); |
| that.props.openStatusDialog(list._id); |
| } |
| }, h('div.circle' + (list.sg ? '.shared' : '')))); |
| } |
| children.push(h('div.display', { |
| key: 'display' |
| }, h('a.list-name' + (list.name ? '' : '.empty'), { |
| href: '/lists/' + list._id, |
| onClick: function(e) { |
| e.preventDefault(); |
| } |
| }, list.name))); |
| } |
| return h('div.list' + (list.selected ? '.selected' : ''), { |
| onClick: function() { |
| that.props.setListId(list._id); |
| }, |
| onDoubleClick: function() { |
| that.setState({editingName: true}); |
| } |
| }, children); |
| } |
| })); |
| |
| var StatusPane = React.createFactory(React.createClass({ |
| displayName: 'StatusPane', |
| componentDidMount: function() { |
| var that = this; |
| Mousetrap.bind('esc', function() { |
| that.props.close(); |
| }); |
| }, |
| componentWillUnmount: function() { |
| Mousetrap.unbind('esc'); |
| }, |
| render: function() { |
| var that = this, list = this.props.list, shared = Boolean(list.sg); |
| var shareUrl; |
| if (shared) { |
| var encodedSgName = util.strToHex(list.sg.name); |
| var loc = window.location; |
| shareUrl = loc.origin + '/share/' + encodedSgName + loc.search; |
| } |
| return h('div#status-pane', { |
| onClick: function(e) { |
| e.stopPropagation(); |
| if (e.target === e.currentTarget) { |
| that.props.close(); |
| } |
| } |
| }, h('div.status-dialog', [ |
| h('h3', {key: 'title'}, 'Share with others'), |
| h('input', _.assign({ |
| key: 'input', |
| placeholder: 'Add email address', |
| }, okCancelEvents({ |
| ok: function(value, e) { |
| e.target.value = ''; |
| if (shared) { |
| // TODO(sadovsky): Let the user add members to an existing SG once |
| // Syncbase supports it. |
| alert('Cannot add members to an existing SyncGroup.'); |
| return; |
| } |
| // TODO(sadovsky): Better input validation. |
| if (!value.includes('@') || !value.includes('.')) { |
| alert('Invalid email address.'); |
| return; |
| } |
| disp.createSyncGroup(disp.listIdToSgName(list._id), [ |
| emailToBlessing(userEmail), |
| emailToBlessing(value) |
| ], mtName, alertOnError); |
| }, |
| cancel: function(e) { |
| e.target.value = ''; |
| } |
| }, true))), |
| // TODO(sadovsky): Exclude self? |
| !shared ? null : h('div.shared-with', {key: 'shared-with'}, [ |
| h('div.subtitle', {key: 'subtitle'}, 'Currently shared with'), |
| h('div.emails', { |
| key: 'emails' |
| }, _.map(list.sg.spec.perms.get('Admin')['in'], function(blessing) { |
| return blessingToEmail(blessing); |
| }).join(', ')) |
| ]), |
| !shared ? null : h('div.url', {key: 'url'}, [ |
| h('div.subtitle', {key: 'subtitle'}, 'URL to share with invitees'), |
| h('div.value', {key: 'value'}, h('a', {href: shareUrl}, shareUrl)) |
| ]), |
| h('div.close', { |
| key: 'close', |
| onClick: function() { |
| that.props.close(); |
| } |
| }) |
| ])); |
| } |
| })); |
| |
| var ListsPane = React.createFactory(React.createClass({ |
| displayName: 'ListsPane', |
| render: function() { |
| var that = this; |
| var children = [h('div.lists-title', {key: 'title'}, 'Todo Lists')]; |
| if (!this.props.lists) { |
| children.push(h('div.loading', {key: 'loading'}, 'Loading...')); |
| } else { |
| var lists = []; |
| _.each(this.props.lists, function(list) { |
| list.selected = that.props.listId === list._id; |
| lists.push(List({ |
| key: list._id, |
| list: list, |
| useSyncbase: that.props.useSyncbase, |
| setListId: that.props.setListId, |
| openStatusDialog: that.props.openStatusDialog |
| })); |
| }); |
| children.push(h('div', {key: 'lists'}, lists)); |
| children.push(h('div.new-list', {key: 'new-list'}, h('input', _.assign({ |
| type: 'text', |
| placeholder: 'New list' |
| }, okCancelEvents({ |
| ok: function(value, e) { |
| disp.addList({name: value}, function(err, listId) { |
| alertOnError(err); |
| that.props.setListId(listId); |
| }); |
| e.target.value = ''; |
| } |
| }))))); |
| } |
| return h('div#lists-pane', children); |
| } |
| })); |
| |
| var DispType = React.createFactory(React.createClass({ |
| render: function() { |
| var that = this; |
| return h('div#disp-type.' + this.props.dispType, { |
| onClick: function() { |
| that.props.toggleDispType(); |
| } |
| }, this.props.dispType); |
| } |
| })); |
| |
| var Page = React.createFactory(React.createClass({ |
| displayName: 'Page', |
| getInitialState: function() { |
| return { |
| dispInitialized: false, |
| // Note, SG data is attached to individual list items. |
| lists: {seq: 0, items: null}, // all lists |
| todos: {}, // map of listId to {seq, items} |
| listId: this.props.initialListId, // current list |
| tagFilter: null, // current tag |
| showStatusDialog: false |
| }; |
| }, |
| getLists_: function(cb) { |
| disp.getLists(function(err, lists) { |
| if (err) return cb(err); |
| // Sort lists by name in the UI. |
| return cb(null, _.sortBy(lists, 'name')); |
| }); |
| }, |
| getTodos_: function(listId, cb) { |
| if (!listId) { |
| return process.nextTick(cb); |
| } |
| disp.getTodos(listId, function(err, todos) { |
| if (err) return cb(err); |
| // Sort todos by timestamp in the UI. |
| return cb(null, _.sortBy(todos, 'timestamp')); |
| }); |
| }, |
| setListId_: function(listId) { |
| if (listId === this.state.listId) return; |
| this.setState({ |
| listId: listId, |
| tagFilter: null |
| }); |
| }, |
| updateURL: function() { |
| var listId = this.state.listId; |
| var pathname = !listId ? '/' : '/lists/' + listId; |
| // Note, this doesn't trigger a re-render; it's purely visual. |
| window.history.replaceState({}, '', pathname + window.location.search); |
| }, |
| // Updates state.lists. Calls cb once the setState call has completed. |
| // TODO(sadovsky): If possible, simplify how we deal with concurrent state |
| // updates, here and elsewhere. The current approach is fairly subtle and |
| // error-prone. Our goal is simple: never show stale data, even in the |
| // presence of sync. |
| updateLists_: function(cb) { |
| var that = this; |
| var listsSeq = this.state.lists.seq + 1; |
| this.getLists_(function(err, lists) { |
| if (err) return cb(err); |
| // Use setState(cb) form to ensure atomicity. |
| // References: https://goo.gl/CZ82Vp and https://goo.gl/vVCp8B |
| that.setState(function(state) { |
| if (listsSeq <= state.lists.seq) { |
| return {}; |
| } |
| return {lists: {seq: listsSeq, items: lists}}; |
| }, cb); |
| }); |
| }, |
| // Updates state.todos[listId]. Calls cb once the setState call has completed. |
| updateTodos_: function(listId, cb) { |
| var that = this; |
| var stateTodos = this.state.todos[listId]; |
| var todosSeq = (stateTodos ? stateTodos.seq : 0) + 1; |
| this.getTodos_(listId, function(err, todos) { |
| if (err) return cb(err); |
| // Use setState(cb) form to ensure atomicity. |
| // References: https://goo.gl/CZ82Vp and https://goo.gl/vVCp8B |
| that.setState(function(state) { |
| var stateTodos = state.todos[listId]; |
| if (stateTodos && todosSeq <= stateTodos.seq) { |
| return {}; |
| } |
| state.todos[listId] = {seq: todosSeq, items: todos}; |
| return {todos: state.todos}; |
| }, cb); |
| }); |
| }, |
| componentWillMount: function() { |
| var that = this, props = this.props; |
| if (props.benchmark) { |
| initVanadium(function(err, rt) { |
| alertOnError(err); |
| bm.runBenchmark(rt, props.syncbaseName, alertOnError); |
| }); |
| return; |
| } |
| initDispatcher(props.dispType, props.syncbaseName, function(err) { |
| alertOnError(err); |
| that.setState({dispInitialized: true}, function() { |
| if (!props.joinSgName) return; |
| // TODO(sadovsky): Show "please wait..." modal? |
| console.assert(props.dispType === DISP_TYPE_SYNCBASE); |
| disp.joinSyncGroup(props.joinSgName, function(err) { |
| // Note, joinSyncGroup is a noop (no error) if the caller is already a |
| // member, which is the desired behavior here. |
| alertOnError(err); |
| var listId = disp.sgNameToListId(props.joinSgName); |
| // TODO(sadovsky): Wait for all items to get synced before attempting |
| // to read them? |
| that.updateTodos_(listId, function(err) { |
| alertOnError(err); |
| // Note, componentDidUpdate() will update the url. |
| that.setState({listId: listId}); |
| }); |
| }); |
| }); |
| }); |
| }, |
| componentDidMount: function() { |
| console.assert(!this.state.dispInitialized); |
| }, |
| componentDidUpdate: function(prevProps, prevState) { |
| var that = this; |
| this.updateURL(); |
| |
| // Only run the code below when disp has just been initialized. |
| if (prevState.dispInitialized || !this.state.dispInitialized) { |
| return; |
| } |
| |
| // Returns the list id for the list that should be displayed. |
| function getListId() { |
| var listId = that.state.listId, lists = that.state.lists.items; |
| // If listId refers to an unknown list, set it to null. |
| if (!_.includes(_.pluck(lists, '_id'), listId)) { |
| listId = null; |
| } |
| // If listId is not set, set it to the id of the first list. |
| if (!listId && lists.length > 0) { |
| listId = lists[0]._id; |
| } |
| return listId; |
| } |
| |
| // TODO(sadovsky): Only read (and only redraw) what's needed based on what |
| // changed. |
| disp.on('change', function(fromSync) { |
| var onChangeDone = util.logFn('onChange', alertOnError); |
| that.updateLists_(function(err) { |
| alertOnError(err); |
| if (fromSync) { |
| var listIds = _.pluck(that.state.lists.items, '_id'); |
| async.each(listIds, function(listId, cb) { |
| that.updateTodos_(listId, cb); |
| }, onChangeDone); |
| } else { |
| var listId = getListId(); |
| that.updateTodos_(listId, onChangeDone); |
| } |
| }); |
| }); |
| |
| // Load initial lists and todos. Note that changes can come in concurrently |
| // via sync. |
| this.updateLists_(function(err) { |
| alertOnError(err); |
| // Set initial listId if needed. |
| var listId = getListId(); |
| if (listId !== that.state.listId) { |
| that.setState({listId: listId}); |
| } |
| // Get todos for all lists. |
| var listIds = _.pluck(that.state.lists.items, '_id'); |
| async.each(listIds, function(listId, cb) { |
| that.updateTodos_(listId, cb); |
| }, function(err) { |
| alertOnError(err); |
| }); |
| }); |
| }, |
| render: function() { |
| if (this.props.benchmark) { |
| return null; |
| } |
| |
| var that = this, listId = this.state.listId; |
| var useSyncbase = this.props.dispType === DISP_TYPE_SYNCBASE; |
| // If currTodos is {}, todos.items will be undefined, as desired. |
| var currTodos = this.state.todos[listId] || {}; |
| return h('div#page-pane', [ |
| DispType({ |
| key: 'DispType', |
| dispType: this.props.dispType, |
| toggleDispType: function() { |
| var newDispType = DISP_TYPE_SYNCBASE; |
| if (that.props.dispType === DISP_TYPE_SYNCBASE) { |
| newDispType = DISP_TYPE_COLLECTION; |
| } |
| window.location.replace('/?d=' + newDispType); |
| } |
| }), |
| ListsPane({ |
| key: 'ListsPane', |
| lists: this.state.lists.items, |
| listId: listId, |
| useSyncbase: useSyncbase, |
| setListId: this.setListId_, |
| openStatusDialog: function(listId) { |
| console.assert(useSyncbase); |
| if (that.state.showStatusDialog) { |
| return; |
| } |
| // Note, setState just schedules render, so setListId's state update |
| // will be merged with ours. |
| that.setListId_(listId); |
| that.setState({showStatusDialog: true}); |
| } |
| }), |
| h('div#tags-and-todos-pane', {key: 'tags-and-todos-pane'}, [ |
| TagsPane({ |
| key: 'TagsPane', |
| todos: currTodos.items, |
| tagFilter: this.state.tagFilter, |
| setTagFilter: function(tagFilter) { |
| that.setState({tagFilter: tagFilter}); |
| } |
| }), |
| TodosPane({ |
| key: 'TodosPane', |
| todos: currTodos.items, |
| listId: listId, |
| tagFilter: this.state.tagFilter |
| }) |
| ]), |
| !this.state.showStatusDialog ? null : StatusPane({ |
| key: 'status', |
| list: _.find(this.state.lists.items, {_id: listId}), |
| close: function() { |
| that.setState({showStatusDialog: false}); |
| } |
| }) |
| ]); |
| } |
| })); |
| |
| //////////////////////////////////////// |
| // Initialization |
| |
| // Start our DOM log module. Developers can press Ctrl+L (or Meta+L) to toggle |
| // visibility of the log. |
| domLog.init(); |
| |
| function render(props) { |
| props = _.assign({ |
| dispType: u.query.d || DISP_TYPE_COLLECTION, |
| syncbaseName: syncbaseName, |
| benchmark: Boolean(u.query.bm) |
| }, props); |
| React.render(Page(props), document.querySelector('#page')); |
| } |
| |
| // Configure Page.js routes. Note, ctx here is a Page.js context object, not a |
| // Vanadium context object. |
| page('/', function(ctx) { |
| render(); |
| }); |
| page('/lists/:listId', function(ctx) { |
| render({initialListId: ctx.params.listId}); |
| }); |
| page('/share/:encodedSgName', function(ctx) { |
| render({joinSgName: util.hexToStr(ctx.params.encodedSgName)}); |
| }); |
| |
| // Start Page.js router. |
| // https://visionmedia.github.io/page.js/ |
| page({click: false}); |