diff --git a/.sequelizerc b/.sequelizerc new file mode 100644 index 000000000..0f26a4e15 --- /dev/null +++ b/.sequelizerc @@ -0,0 +1,10 @@ +require('localenv'); + +var path = require('path'); + +module.exports = { + 'config': path.resolve('server/config', 'database.json'), + 'migrations-path': path.resolve('server', 'migrations'), + 'models-path': path.resolve('server', 'models'), + 'seeders-path': path.resolve('server/models', 'fixtures'), +} diff --git a/package.json b/package.json index 80d5086d4..d43dfba2a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "start": "cross-env NODE_ENV=development DEBUG=1 ./node_modules/.bin/nodemon --watch server index.js", "lint": "eslint src", "deploy": "git push heroku master", - "heroku-postbuild": "npm run build" + "heroku-postbuild": "npm run build && npm run sequelize db:migrate", + "sequelize": "./node_modules/.bin/sequelize" }, "repository": { "type": "git", @@ -53,6 +54,7 @@ "http-errors": "^1.4.0", "imports-loader": "^0.6.5", "isomorphic-fetch": "^2.2.1", + "js-tree": "^1.1.0", "json-loader": "^0.5.4", "jsonwebtoken": "^5.7.0", "koa": "^2.0.0", @@ -96,7 +98,7 @@ "safestart": "^0.8.0", "sass-loader": "^3.2.0", "sequelize": "^3.21.0", - "sequelize-cli": "^2.3.1", + "sequelize-cli": "^2.4.0", "sequelize-encrypted": "^0.1.0", "slug": "^0.9.1", "style-loader": "^0.13.0", diff --git a/server/api/auth.js b/server/api/auth.js index 882637a6f..1f00e0663 100644 --- a/server/api/auth.js +++ b/server/api/auth.js @@ -40,13 +40,27 @@ router.post('auth.slack', async (ctx) => { const authResponse = await fetch(`https://slack.com/api/auth.test?token=${data.access_token}`); const authData = await authResponse.json(); + // Team + let team = await Team.findOne({ where: { slackId: data.team.id } }); + if (!team) { + team = await Team.create({ + name: data.team.name, + slackId: data.team.id, + slackData: data.team, + }); + const atlas = await team.createFirstAtlas(); + } else { + team.name = data.team.name; + team.slackData = data.team; + team = await team.save(); + } + if (user) { user.slackAccessToken = data.access_token; user.slackData = data.user; user = await user.save(); } else { - // Existing user - user = await User.create({ + user = await team.createUser({ slackId: data.user.id, username: authData.user, name: data.user.name, @@ -56,30 +70,11 @@ router.post('auth.slack', async (ctx) => { }); } - // Team - let team = await Team.findOne({ where: { slackId: data.team.id } }); - if (!team) { - team = await Team.create({ - name: data.team.name, - slackId: data.team.id, - slackData: data.team, - }); - } else { - // Update data - team.name = data.team.name; - team.slackData = data.team; - team = await team.save(); - } - - // Add to correct team - user.setTeam(team); - ctx.body = { data: { user: await presentUser(user), team: await presentTeam(team), accessToken: user.getJwtToken(), }}; - console.log("enf") }); export default router; diff --git a/server/api/atlases.js b/server/api/collections.js similarity index 64% rename from server/api/atlases.js rename to server/api/collections.js index 44a6921e9..3234d4d01 100644 --- a/server/api/atlases.js +++ b/server/api/collections.js @@ -13,11 +13,11 @@ router.post('atlases.info', auth(), async (ctx) => { let { id } = ctx.request.body; ctx.assertPresent(id, 'id is required'); - const team = await ctx.state.user.getTeam(); + const user = ctx.state.user; const atlas = await Atlas.findOne({ where: { id: id, - teamId: team.id, + teamId: user.teamId, }, }); @@ -30,10 +30,10 @@ router.post('atlases.info', auth(), async (ctx) => { router.post('atlases.list', auth(), pagination(), async (ctx) => { - const team = await ctx.state.user.getTeam(); + const user = ctx.state.user; const atlases = await Atlas.findAll({ where: { - teamId: team.id, + teamId: user.teamId, }, order: [ ['updatedAt', 'DESC'], @@ -56,4 +56,26 @@ router.post('atlases.list', auth(), pagination(), async (ctx) => { }; }); -export default router; \ No newline at end of file +router.post('atlases.updateNavigationTree', auth(), async (ctx) => { + let { id, tree } = ctx.request.body; + ctx.assertPresent(id, 'id is required'); + + const user = ctx.state.user; + const atlas = await Atlas.findOne({ + where: { + id: id, + teamId: user.teamId, + }, + }); + + if (!atlas) throw httpErrors.NotFound(); + + const newTree = await atlas.updateNavigationTree(tree); + + ctx.body = { + data: await presentAtlas(atlas, true), + tree: newTree, + }; +}); + +export default router; diff --git a/server/api/documents.js b/server/api/documents.js index 9a02389b5..9d8e0f946 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -23,8 +23,8 @@ router.post('documents.info', auth({ require: false }), async (ctx) => { if (document.private) { if (!ctx.state.user) throw httpErrors.NotFound(); - const team = await ctx.state.user.getTeam(); - if (document.teamId !== team.id) { + const user = await ctx.state.user; + if (document.teamId !== user.teamId) { throw httpErrors.NotFound(); } @@ -46,30 +46,45 @@ router.post('documents.create', auth(), async (ctx) => { atlas, title, text, + parentDocument, } = ctx.request.body; ctx.assertPresent(atlas, 'atlas is required'); ctx.assertPresent(title, 'title is required'); ctx.assertPresent(text, 'text is required'); const user = ctx.state.user; - const team = await user.getTeam(); const ownerAtlas = await Atlas.findOne({ where: { id: atlas, - teamId: team.id, + teamId: user.teamId, }, }); if (!ownerAtlas) throw httpErrors.BadRequest(); + let parentDocumentObj; + if (parentDocument && ownerAtlas.type === 'atlas') { + parentDocumentObj = await Document.findOne({ + where: { + id: parentDocument, + atlasId: ownerAtlas.id, + }, + }); + } + const document = await Document.create({ + parentDocumentId: parentDocumentObj.id, atlasId: ownerAtlas.id, - teamId: team.id, + teamId: user.teamId, userId: user.id, title: title, text: text, }); + // TODO: Move to afterSave hook if possible with imports + ownerAtlas.addNodeToNavigationTree(document); + await ownerAtlas.save(); + ctx.body = { data: await presentDocument(document, true), }; @@ -86,11 +101,10 @@ router.post('documents.update', auth(), async (ctx) => { ctx.assertPresent(text, 'text is required'); const user = ctx.state.user; - const team = await user.getTeam(); let document = await Document.findOne({ where: { id: id, - teamId: team.id, + teamId: user.teamId, }, }); @@ -112,16 +126,18 @@ router.post('documents.delete', auth(), async (ctx) => { ctx.assertPresent(id, 'id is required'); const user = ctx.state.user; - const team = await user.getTeam(); let document = await Document.findOne({ where: { id: id, - teamId: team.id, + teamId: user.teamId, }, }); if (!document) throw httpErrors.BadRequest(); + // TODO: Don't allow to destroy root docs + // TODO: handle sub documents + try { await document.destroy(); } catch (e) { diff --git a/server/api/index.js b/server/api/index.js index 302eb1953..11f8fc09d 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -6,7 +6,7 @@ import Sequelize from 'sequelize'; import auth from './auth'; import user from './user'; -import atlases from './atlases'; +import collections from './collections'; import documents from './documents'; import validation from './validation'; @@ -44,7 +44,7 @@ api.use(validation()); router.use('/', auth.routes()); router.use('/', user.routes()); -router.use('/', atlases.routes()); +router.use('/', collections.routes()); router.use('/', documents.routes()); // Router is embedded in a Koa application wrapper, because koa-router does not diff --git a/server/config/database.json b/server/config/database.json new file mode 100644 index 000000000..7d739b971 --- /dev/null +++ b/server/config/database.json @@ -0,0 +1,14 @@ +{ + "development": { + "use_env_variable": "DATABASE_URL", + "dialect": "postgres" + }, + "test": { + "use_env_variable": "DATABASE_URL", + "dialect": "postgres" + }, + "production": { + "use_env_variable": "DATABASE_URL", + "dialect": "postgres" + } +} diff --git a/server/migrations/20160619080644-initial.js b/server/migrations/20160619080644-initial.js new file mode 100644 index 000000000..bc3c6373a --- /dev/null +++ b/server/migrations/20160619080644-initial.js @@ -0,0 +1,199 @@ +'use strict'; + +module.exports = { + up: function (queryInterface, Sequelize) { + queryInterface.createTable('teams', { + id: { + type: 'UUID', + allowNull: false, + primaryKey: true + }, + name: { + type: 'CHARACTER VARYING', + allowNull: true, + }, + slackId: { + type: 'CHARACTER VARYING', + allowNull: true, + unique: true + }, + slackData: { + type: 'JSONB', + allowNull: true, + }, + createdAt: { + type: 'TIMESTAMP WITH TIME ZONE', + allowNull: false, + }, + updatedAt: { + type: 'TIMESTAMP WITH TIME ZONE', + allowNull: false, + } + }); + + queryInterface.createTable('atlases', { + id: { + type: 'UUID', + allowNull: false, + primaryKey: true + }, + name: { + type: 'CHARACTER VARYING', + allowNull: true, + }, + description: { + type: 'CHARACTER VARYING', + allowNull: true, + }, + type: { + type: 'CHARACTER VARYING', + allowNull: true, + }, + atlasStructure: { + type: 'JSONB', + allowNull: true, + }, + createdAt: { + type: 'TIMESTAMP WITH TIME ZONE', + allowNull: false, + }, + updatedAt: { + type: 'TIMESTAMP WITH TIME ZONE', + allowNull: false, + }, + teamId: { + type: 'UUID', + allowNull: false, + // references: { + // model: "teams", + // key: "id", + // } + } + }); + + queryInterface.createTable('users', { + id: { + type: 'UUID', + allowNull: false, + primaryKey: true + }, + email: { + type: 'CHARACTER VARYING', + allowNull: false, + }, + username: { + type: 'CHARACTER VARYING', + allowNull: false, + }, + name: { + type: 'CHARACTER VARYING', + allowNull: false, + }, + isAdmin: { + type: 'BOOLEAN', + allowNull: true, + defaultValue: false, + }, + slackAccessToken: { + type: 'bytea', + allowNull: true, }, + slackId: { + type: 'CHARACTER VARYING', + unique: true, + allowNull: false, + }, + slackData: { + type: 'JSONB', + allowNull: true, + }, + jwtSecret: { + type: 'bytea', + allowNull: true, + }, + createdAt: { + type: 'TIMESTAMP WITH TIME ZONE', + allowNull: false, + }, + updatedAt: { + type: 'TIMESTAMP WITH TIME ZONE', + allowNull: false, + }, + teamId: { + type: 'UUID', + allowNull: true, + // references: { + // model: "teams", + // key: "id", + // } + } + }); + + queryInterface.createTable('documents', { + id: + { type: 'UUID', + allowNull: false, + primaryKey: true }, + urlId: + { type: 'CHARACTER VARYING', + allowNull: false, + unique: true, }, + private: + { type: 'BOOLEAN', + allowNull: false, + defaultValue: true, + }, + title: + { type: 'CHARACTER VARYING', + allowNull: false, + }, + text: + { type: 'TEXT', + allowNull: true, + }, + html: + { type: 'TEXT', + allowNull: true, + }, + preview: + { type: 'TEXT', + allowNull: true, + }, + createdAt: + { type: 'TIMESTAMP WITH TIME ZONE', + allowNull: false, + }, + updatedAt: + { type: 'TIMESTAMP WITH TIME ZONE', + allowNull: false, + }, + userId: { + type: 'UUID', + allowNull: true, + // references: { + // model: "users", + // key: "id", + // } + }, + atlasId: { + type: 'UUID', + allowNull: true, + // references: { + // model: "atlases", + // key: "id", + // } + }, + teamId: { + type: 'UUID', + allowNull: true, + // references: { + // model: "teams", + // key: "id", + // } + } + }); + }, + + down: function (queryInterface, Sequelize) { + queryInterface.dropAllTables(); + } +}; diff --git a/server/migrations/20160622043741-add-parent-document.js b/server/migrations/20160622043741-add-parent-document.js new file mode 100644 index 000000000..eda6ae28d --- /dev/null +++ b/server/migrations/20160622043741-add-parent-document.js @@ -0,0 +1,22 @@ +'use strict'; + +module.exports = { + up: function (queryInterface, Sequelize) { + queryInterface.addColumn( + 'documents', + 'parentDocumentId', + { + type: Sequelize.UUID, + allowNull: true, + references: { + model: "documents", + key: "id", + } + } + ); + }, + + down: function (queryInterface, Sequelize) { + queryInterface.removeColumn('documents', 'parentDocumentId'); + } +}; diff --git a/server/models/Atlas.js b/server/models/Atlas.js index 175bd1ba1..f6f778d22 100644 --- a/server/models/Atlas.js +++ b/server/models/Atlas.js @@ -2,7 +2,8 @@ import { DataTypes, sequelize, } from '../sequelize'; -import Team from './Team'; +import _isEqual from 'lodash/isEqual'; +import Document from './Document'; const allowedAtlasTypes = [['atlas', 'journal']]; @@ -11,8 +12,146 @@ const Atlas = sequelize.define('atlas', { name: DataTypes.STRING, description: DataTypes.STRING, type: { type: DataTypes.STRING, validate: { isIn: allowedAtlasTypes }}, + + /* type: atlas */ + navigationTree: DataTypes.JSONB, +}, { + tableName: 'atlases', + hooks: { + // beforeValidate: (doc) => { + // doc.urlId = randomstring.generate(15); + // }, + // beforeCreate: (doc) => { + // doc.html = convertToMarkdown(doc.text); + // doc.preview = truncateMarkdown(doc.text, 160); + // }, + // beforeUpdate: (doc) => { + // doc.html = convertToMarkdown(doc.text); + // doc.preview = truncateMarkdown(doc.text, 160); + // }, + }, + instanceMethods: { + async getStructure() { + if (this.navigationTree) { + return this.navigationTree; + } + + const getNodeForDocument = async (document) => { + const children = await Document.findAll({ where: { + parentDocumentId: document.id, + atlasId: this.id, + }}); + + let childNodes = [] + await Promise.all(children.map(async (child) => { + childNodes.push(await getNodeForDocument(child)); + })); + + return { + title: document.title, + id: document.id, + url: document.getUrl(), + children: childNodes, + }; + } + + const rootDocument = await Document.findOne({ + where: { + parentDocumentId: null, + atlasId: this.id, + } + }); + + if (rootDocument) { + return await getNodeForDocument(rootDocument); + } else { + return; // TODO should create a root doc + } + }, + async updateNavigationTree(tree) { + let nodeIds = []; + nodeIds.push(tree.id); + + const rootDocument = await Document.findOne({ + where: { + id: tree.id, + atlasId: this.id, + }, + }); + if (!rootDocument) throw new Error; + + let newTree = { + id: tree.id, + title: rootDocument.title, + url: rootDocument.getUrl(), + children: [], + }; + + const getIdsForChildren = async (children) => { + const childNodes = []; + for (const child of children) { + const childDocument = await Document.findOne({ + where: { + id: child.id, + atlasId: this.id, + }, + }); + if (!childDocument) throw new Error; + + childNodes.push({ + id: childDocument.id, + title: childDocument.title, + url: childDocument.getUrl(), + children: await getIdsForChildren(child.children), + }) + nodeIds.push(child.id); + } + return childNodes; + }; + newTree.children = await getIdsForChildren(tree.children); + + const documents = await Document.findAll({ + attributes: ['id'], + where: { + atlasId: this.id, + } + }); + const documentIds = documents.map(doc => doc.id); + + if (!_isEqual(nodeIds.sort(), documentIds.sort())) { + throw new Error('Invalid navigation tree'); + } + + this.navigationTree = newTree; + await this.save(); + + return newTree; + }, + async addNodeToNavigationTree(document) { + const newNode = { + id: document.id, + title: document.title, + url: document.getUrl(), + children: [], + } + + const insertNode = (node) => { + if (document.parentDocumentId === node.id) { + node.children.push(newNode); + } else { + node.children = node.children.map(childNode => { + return insertNode(childNode); + }) + } + + return node; + }; + + this.navigationTree = insertNode(this.navigationTree); + } + } }); -Atlas.belongsTo(Team); +Atlas.hasMany(Document, { as: 'documents', foreignKey: 'atlasId' }); export default Atlas; diff --git a/server/models/Document.js b/server/models/Document.js index d0413afc1..db3e323bd 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -10,8 +10,6 @@ import { import { truncateMarkdown, } from '../utils/truncate'; -import Atlas from './Atlas'; -import Team from './Team'; import User from './User'; slug.defaults.mode ='rfc3986'; @@ -29,6 +27,8 @@ const Document = sequelize.define('document', { text: DataTypes.TEXT, html: DataTypes.TEXT, preview: DataTypes.TEXT, + + parentDocumentId: DataTypes.UUID, }, { hooks: { beforeValidate: (doc) => { @@ -47,12 +47,13 @@ const Document = sequelize.define('document', { buildUrl() { const slugifiedTitle = slug(this.title); return `${slugifiedTitle}-${this.urlId}`; - } + }, + getUrl() { + return `/documents/${ this.id }`; + }, } }); -Document.belongsTo(Atlas, { as: 'atlas' }); -Document.belongsTo(Team); Document.belongsTo(User); export default Document; diff --git a/server/models/Team.js b/server/models/Team.js index 9c933d259..bd6e4e1e5 100644 --- a/server/models/Team.js +++ b/server/models/Team.js @@ -2,6 +2,9 @@ import { DataTypes, sequelize, } from '../sequelize'; +import Atlas from './Atlas'; +import Document from './Document'; +import User from './User'; const Team = sequelize.define('team', { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, @@ -9,6 +12,17 @@ const Team = sequelize.define('team', { slackId: { type: DataTypes.STRING, unique: true }, slackData: DataTypes.JSONB, }, { + instanceMethods: { + async createFirstAtlas() { + const atlas = await Atlas.create({ + name: this.name, + description: 'Your first Atlas', + type: 'journal', + teamId: this.id, + }); + return atlas; + } + }, indexes: [ { unique: true, @@ -17,4 +31,8 @@ const Team = sequelize.define('team', { ], }); +Team.hasMany(Atlas, { as: 'atlases' }); +Team.hasMany(Document, { as: 'documents' }); +Team.hasMany(User, { as: 'users' }); + export default Team; diff --git a/server/models/User.js b/server/models/User.js index 64452ab8e..6eab90862 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -4,7 +4,6 @@ import { sequelize, encryptedFields } from '../sequelize'; -import Team from './Team'; import JWT from 'jsonwebtoken'; @@ -39,8 +38,5 @@ const setRandomJwtSecret = (model) => { }; User.beforeCreate(setRandomJwtSecret); -User.belongsTo(Team); - -sequelize.sync(); export default User; diff --git a/server/presenters.js b/server/presenters.js index 7c3977ec2..49218b1d5 100644 --- a/server/presenters.js +++ b/server/presenters.js @@ -1,5 +1,5 @@ import _orderBy from 'lodash.orderby'; -import Document from './models/Document'; +import { Document, Atlas } from './models'; export function presentUser(user) { return new Promise(async (resolve, reject) => { @@ -31,6 +31,10 @@ export function presentAtlas(atlas, includeRecentDocuments=false) { type: atlas.type, } + if (atlas.type === 'atlas') { + data.navigationTree = await atlas.getStructure(); + } + if (includeRecentDocuments) { const documents = await Document.findAll({ where: { @@ -65,12 +69,14 @@ export async function presentDocument(document, includeAtlas=false) { private: document.private, createdAt: document.createdAt, updatedAt: document.updatedAt, - atlas: document.atlaId, + atlas: document.atlasId, team: document.teamId, } if (includeAtlas) { - const atlas = await document.getAtlas(); + const atlas = await Atlas.findOne({ where: { + id: document.atlasId, + }}); data.atlas = await presentAtlas(atlas, false); } diff --git a/src/assets/icons/anchor.svg b/src/assets/icons/anchor.svg deleted file mode 100644 index f98164372..000000000 --- a/src/assets/icons/anchor.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/Document/Document.scss b/src/components/Document/Document.scss index 7aff8e271..d5ad2a10d 100644 --- a/src/components/Document/Document.scss +++ b/src/components/Document/Document.scss @@ -8,13 +8,7 @@ :global { .anchor { visibility: hidden; - background-image: url('../../assets/icons/anchor.svg'); - background-repeat: no-repeat; - background-size: 100%; - background-position: 0 center; - margin-left: -26px; - width: 20px; - display: inline-block; + color: #ccc; } } @@ -28,9 +22,10 @@ } ul { + padding-left: 1.5em; + ul { margin: 0; - padding-left: 1.5em; } } } \ No newline at end of file diff --git a/src/components/Tree/Node.js b/src/components/Tree/Node.js new file mode 100644 index 000000000..ab980dcff --- /dev/null +++ b/src/components/Tree/Node.js @@ -0,0 +1,110 @@ +var React = require('react'); +import history from 'utils/History'; + +import styles from './Tree.scss'; +import classNames from 'classnames/bind'; +const cx = classNames.bind(styles); + +var Node = React.createClass({ + displayName: 'UITreeNode', + + renderCollapse() { + var index = this.props.index; + + if(index.children && index.children.length) { + var collapsed = index.node.collapsed; + + return ( + + + + ); + } + + return null; + }, + + renderChildren() { + var index = this.props.index; + var tree = this.props.tree; + var dragging = this.props.dragging; + + if(index.children && index.children.length) { + var childrenStyles = {}; + + if (!this.props.rootNode) { + if(index.node.collapsed) childrenStyles.display = 'none'; + childrenStyles['paddingLeft'] = this.props.paddingLeft + 'px'; + } + + return ( +
+ {index.children.map((child) => { + var childIndex = tree.getIndex(child); + return ( + + ); + })} +
+ ); + } + + return null; + }, + + render() { + var tree = this.props.tree; + var index = this.props.index; + var dragging = this.props.dragging; + var node = index.node; + var style = {}; + + return ( +
+
+ {!this.props.rootNode && this.renderCollapse()} + { history.push(node.url) }} + onMouseDown={this.props.rootNode ? function(e){e.stopPropagation()} : undefined} + > + { node.title } + +
+ {this.renderChildren()} +
+ ); + }, + + handleCollapse(e) { + e.stopPropagation(); + var nodeId = this.props.index.id; + if(this.props.onCollapse) this.props.onCollapse(nodeId); + }, + + handleMouseDown(e) { + var nodeId = this.props.index.id; + var dom = this.refs.inner; + + if(this.props.onDragStart) { + this.props.onDragStart(nodeId, dom, e); + } + } +}); + +module.exports = Node; \ No newline at end of file diff --git a/src/components/Tree/Tree.js b/src/components/Tree/Tree.js new file mode 100644 index 000000000..f5d4b8e3f --- /dev/null +++ b/src/components/Tree/Tree.js @@ -0,0 +1,68 @@ +var Tree = require('js-tree'); +var proto = Tree.prototype; + +proto.updateNodesPosition = function() { + var top = 1; + var left = 1; + var root = this.getIndex(1); + var self = this; + + root.top = top++; + root.left = left++; + + if(root.children && root.children.length) { + walk(root.children, root, left, root.node.collapsed); + } + + function walk(children, parent, left, collapsed) { + var height = 1; + children.forEach(function(id) { + var node = self.getIndex(id); + if(collapsed) { + node.top = null; + node.left = null; + } else { + node.top = top++; + node.left = left; + } + + if(node.children && node.children.length) { + height += walk(node.children, node, left+1, collapsed || node.node.collapsed); + } else { + node.height = 1; + height += 1; + } + }); + + if(parent.node.collapsed) parent.height = 1; + else parent.height = height; + return parent.height; + } +}; + +proto.move = function(fromId, toId, placement) { + if(fromId === toId || toId === 1) return; + + var obj = this.remove(fromId); + var index = null; + + if(placement === 'before') index = this.insertBefore(obj, toId); + else if(placement === 'after') index = this.insertAfter(obj, toId); + else if(placement === 'prepend') index = this.prepend(obj, toId); + else if(placement === 'append') index = this.append(obj, toId); + + // todo: perf + this.updateNodesPosition(); + return index; +}; + +proto.getNodeByTop = function(top) { + var indexes = this.indexes; + for(var id in indexes) { + if(indexes.hasOwnProperty(id)) { + if(indexes[id].top === top) return indexes[id]; + } + } +}; + +module.exports = Tree; \ No newline at end of file diff --git a/src/components/Tree/Tree.scss b/src/components/Tree/Tree.scss new file mode 100644 index 000000000..219c4e8e7 --- /dev/null +++ b/src/components/Tree/Tree.scss @@ -0,0 +1,79 @@ +@mixin no-select { + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.tree { + position: relative; + overflow: hidden; + @include no-select; +} + +.draggable { + position: absolute; + opacity: 0.8; + @include no-select; +} + +.node { + &.placeholder > * { + visibility: hidden; + } + + &.placeholder { + border: 1px dashed #ccc; + } + + .inner { + position: relative; + cursor: pointer; + padding-left: 10px; + } + + .collapse { + position: absolute; + left: 0; + cursor: pointer; + + width: 20px; + height: 25px; + } + + .caretRight { + margin-top: 3px; + margin-left: -3px; + } + + .caretDown { + transform: rotate(90deg); + margin-left: -4px; + margin-top: 2px; + } +} + +.node { + &.placeholder { + border: 1px dashed #1385e5; + } + + .inner { + font-size: 14px; + } + + .nodeLabel { + display: inline-block; + width: 100%; + padding: 4px 5px; + + &.isActive { + background-color: #31363F; + } + } + + .rootLabel { + color: #ccc; + } +} \ No newline at end of file diff --git a/src/components/Tree/UiTree.js b/src/components/Tree/UiTree.js new file mode 100644 index 000000000..640be2c79 --- /dev/null +++ b/src/components/Tree/UiTree.js @@ -0,0 +1,266 @@ +var React = require('react'); +var Tree = require('./tree'); +var Node = require('./node'); + +import styles from './Tree.scss'; + +module.exports = React.createClass({ + displayName: 'UITree', + + propTypes: { + tree: React.PropTypes.object.isRequired, + paddingLeft: React.PropTypes.number, + renderNode: React.PropTypes.func.isRequired + }, + + getDefaultProps() { + return { + paddingLeft: 20 + }; + }, + + getInitialState() { + return this.init(this.props); + }, + + componentWillReceiveProps(nextProps) { + if(!this._updated) this.setState(this.init(nextProps)); + else this._updated = false; + }, + + init(props) { + var tree = new Tree(props.tree); + tree.isNodeCollapsed = props.isNodeCollapsed; + tree.renderNode = props.renderNode; + tree.changeNodeCollapsed = props.changeNodeCollapsed; + tree.updateNodesPosition(); + + return { + tree: tree, + dragging: { + id: null, + x: null, + y: null, + w: null, + h: null + } + }; + }, + + getDraggingDom() { + var tree = this.state.tree; + var dragging = this.state.dragging; + + if(dragging && dragging.id) { + var draggingIndex = tree.getIndex(dragging.id); + var draggingStyles = { + top: dragging.y, + left: dragging.x, + width: dragging.w + }; + + return ( +
+ +
+ ); + } + + return null; + }, + + render() { + var tree = this.state.tree; + var dragging = this.state.dragging; + var draggingDom = this.getDraggingDom(); + + return ( +
+ {draggingDom} + +
+ ); + }, + + dragStart(id, dom, e) { + this.dragging = { + id: id, + w: dom.offsetWidth, + h: dom.offsetHeight, + x: dom.offsetLeft, + y: dom.offsetTop + }; + + this._startX = dom.offsetLeft; + this._startY = dom.offsetTop; + this._offsetX = e.clientX; + this._offsetY = e.clientY; + this._start = true; + + window.addEventListener('mousemove', this.drag); + window.addEventListener('mouseup', this.dragEnd); + }, + + // oh + drag(e) { + if(this._start) { + this.setState({ + dragging: this.dragging + }); + this._start = false; + } + + var tree = this.state.tree; + var dragging = this.state.dragging; + var paddingLeft = this.props.paddingLeft; + var newIndex = null; + var index = tree.getIndex(dragging.id); + var collapsed = index.node.collapsed; + + var _startX = this._startX; + var _startY = this._startY; + var _offsetX = this._offsetX; + var _offsetY = this._offsetY; + + var pos = { + x: _startX + e.clientX - _offsetX, + y: _startY + e.clientY - _offsetY + }; + dragging.x = pos.x; + dragging.y = pos.y; + + var diffX = dragging.x - paddingLeft/2 - (index.left-2) * paddingLeft; + var diffY = dragging.y - dragging.h/2 - (index.top-2) * dragging.h; + + if(diffX < 0) { // left + if(index.parent && !index.next) { + newIndex = tree.move(index.id, index.parent, 'after'); + } + } else if(diffX > paddingLeft) { // right + if(index.prev) { + var prevNode = tree.getIndex(index.prev).node; + if(!prevNode.collapsed && !prevNode.leaf) { + newIndex = tree.move(index.id, index.prev, 'append'); + } + } + } + + if(newIndex) { + index = newIndex; + newIndex.node.collapsed = collapsed; + dragging.id = newIndex.id; + } + + if(diffY < 0) { // up + var above = tree.getNodeByTop(index.top-1); + newIndex = tree.move(index.id, above.id, 'before'); + } else if(diffY > dragging.h) { // down + if(index.next) { + var below = tree.getIndex(index.next); + if(below.children && below.children.length && !below.node.collapsed) { + newIndex = tree.move(index.id, index.next, 'prepend'); + } else { + newIndex = tree.move(index.id, index.next, 'after'); + } + } else { + var below = tree.getNodeByTop(index.top+index.height); + if(below && below.parent !== index.id) { + if(below.children && below.children.length) { + newIndex = tree.move(index.id, below.id, 'prepend'); + } else { + newIndex = tree.move(index.id, below.id, 'after'); + } + } + } + } + + if(newIndex) { + newIndex.node.collapsed = collapsed; + dragging.id = newIndex.id; + } + + this.setState({ + tree: tree, + dragging: dragging + }); + }, + + dragEnd() { + this.setState({ + dragging: { + id: null, + x: null, + y: null, + w: null, + h: null + } + }); + + this.change(this.state.tree); + window.removeEventListener('mousemove', this.drag); + window.removeEventListener('mouseup', this.dragEnd); + }, + + change(tree) { + this._updated = true; + if(this.props.onChange) this.props.onChange(tree.obj); + }, + + toggleCollapse(nodeId) { + var tree = this.state.tree; + var index = tree.getIndex(nodeId); + var node = index.node; + node.collapsed = !node.collapsed; + tree.updateNodesPosition(); + + this.setState({ + tree: tree + }); + + this.change(tree); + }, + + // buildTreeNumbering(tree) { + // const numberBuilder = (index, node, parentNumbering) => { + // let numbering = parentNumbering ? `${parentNumbering}.${index}` : index; + // let children; + // if (node.children) { + // children = node.children.map((child, childIndex) => { + // return numberBuilder(childIndex+1, child, numbering); + // }); + // } + + // const data = { + // module: { + // ...node.module, + // index: numbering, + // } + // } + // if (children) { + // data.children = children; + // } + + // return data; + // }; + + // const newTree = {...tree}; + // newTree.children = []; + // tree.children.forEach((child, index) => { + // newTree.children.push(numberBuilder(index+1, child)); + // }) + // return newTree; + // } +}); diff --git a/src/components/Tree/assets/chevron.svg b/src/components/Tree/assets/chevron.svg new file mode 100644 index 000000000..4daab5920 --- /dev/null +++ b/src/components/Tree/assets/chevron.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Tree/index.js b/src/components/Tree/index.js new file mode 100644 index 000000000..282ed6e1b --- /dev/null +++ b/src/components/Tree/index.js @@ -0,0 +1,2 @@ +import UiTree from './UiTree'; +export default UiTree; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 3b1a83ff8..38cca8e34 100644 --- a/src/index.js +++ b/src/index.js @@ -44,6 +44,7 @@ render(( + diff --git a/src/scenes/Atlas/Atlas.js b/src/scenes/Atlas/Atlas.js index 521426139..d689841a6 100644 --- a/src/scenes/Atlas/Atlas.js +++ b/src/scenes/Atlas/Atlas.js @@ -1,6 +1,7 @@ import React from 'react'; import { observer } from 'mobx-react'; import Link from 'react-router/lib/Link'; +import History from 'utils/History'; import store from './AtlasStore'; @@ -16,7 +17,13 @@ import styles from './Atlas.scss'; class Atlas extends React.Component { componentDidMount = () => { const { id } = this.props.params; - store.fetchAtlas(id); + store.fetchAtlas(id, data => { + + // Forward directly to root document + if (data.type === 'atlas') { + History.replace(data.navigationTree.url); + } + }) } render() { diff --git a/src/scenes/Atlas/AtlasStore.js b/src/scenes/Atlas/AtlasStore.js index d103de558..2eb026d33 100644 --- a/src/scenes/Atlas/AtlasStore.js +++ b/src/scenes/Atlas/AtlasStore.js @@ -8,7 +8,7 @@ const store = new class AtlasStore { /* Actions */ - @action fetchAtlas = async (id) => { + @action fetchAtlas = async (id, successCallback) => { this.isFetching = true; this.atlas = null; @@ -16,6 +16,7 @@ const store = new class AtlasStore { const res = await client.post('/atlases.info', { id: id }); const { data } = res; this.atlas = data; + successCallback(data); } catch (e) { console.error("Something went wrong"); } diff --git a/src/scenes/DocumentEdit/DocumentEdit.js b/src/scenes/DocumentEdit/DocumentEdit.js index fd011a507..ceacce94b 100644 --- a/src/scenes/DocumentEdit/DocumentEdit.js +++ b/src/scenes/DocumentEdit/DocumentEdit.js @@ -26,6 +26,10 @@ class DocumentEdit extends Component { if (this.props.route.newDocument) { store.atlasId = this.props.params.id; store.newDocument = true; + } else if (this.props.route.newChildDocument) { + store.documentId = this.props.params.id; + store.newChildDocument = true; + store.fetchDocument(); } else { store.documentId = this.props.params.id; store.newDocument = false; @@ -44,7 +48,7 @@ class DocumentEdit extends Component { // alert("Please add a title before saving (hint: Write a markdown header)"); // return // } - if (store.newDocument) { + if (store.newDocument || store.newChildDocument) { store.saveDocument(); } else { store.updateDocument(); diff --git a/src/scenes/DocumentEdit/DocumentEditStore.js b/src/scenes/DocumentEdit/DocumentEditStore.js index a2648ffa1..155d778b1 100644 --- a/src/scenes/DocumentEdit/DocumentEditStore.js +++ b/src/scenes/DocumentEdit/DocumentEditStore.js @@ -18,9 +18,11 @@ const parseHeader = (text) => { const documentEditStore = new class DocumentEditStore { @observable documentId = null; @observable atlasId = null; + @observable parentDocument; @observable title; @observable text; @observable newDocument; + @observable newChildDocument; @observable preview; @observable isFetching; @@ -35,9 +37,13 @@ const documentEditStore = new class DocumentEditStore { const data = await client.post('/documents.info', { id: this.documentId, }) - const { title, text } = data.data; - this.title = title; - this.text = text; + if (this.newDocument) { + const { title, text } = data.data; + this.title = title; + this.text = text; + } else { + this.parentDocument = data.data; + } } catch (e) { console.error("Something went wrong"); } @@ -51,7 +57,8 @@ const documentEditStore = new class DocumentEditStore { try { const data = await client.post('/documents.create', { - atlas: this.atlasId, + parentDocument: this.parentDocument && this.parentDocument.id, + atlas: this.atlasId || this.parentDocument.atlas.id, title: this.title, text: this.text, }) diff --git a/src/scenes/DocumentEdit/components/SaveAction.js b/src/scenes/DocumentEdit/components/SaveAction.js index 948cb59e0..9f509d797 100644 --- a/src/scenes/DocumentEdit/components/SaveAction.js +++ b/src/scenes/DocumentEdit/components/SaveAction.js @@ -3,7 +3,7 @@ import { observer } from 'mobx-react'; @observer class SaveAction extends React.Component { - propTypes = { + static propTypes = { onClick: React.PropTypes.func.isRequired, disabled: React.PropTypes.bool, } diff --git a/src/scenes/DocumentScene/DocumentScene.js b/src/scenes/DocumentScene/DocumentScene.js index b9bf84956..e8883207a 100644 --- a/src/scenes/DocumentScene/DocumentScene.js +++ b/src/scenes/DocumentScene/DocumentScene.js @@ -9,8 +9,14 @@ import AtlasPreviewLoading from 'components/AtlasPreviewLoading'; import CenteredContent from 'components/CenteredContent'; import Document from 'components/Document'; import DropdownMenu, { MenuItem } from 'components/DropdownMenu'; +import Flex from 'components/Flex'; +import Tree from 'components/Tree'; import styles from './DocumentScene.scss'; +import classNames from 'classnames/bind'; +const cx = classNames.bind(styles); + +import treeStyles from 'components/Tree/Tree.scss'; @observer class DocumentScene extends React.Component { @@ -24,6 +30,13 @@ class DocumentScene extends React.Component { } componentWillReceiveProps = (nextProps) => { + // Reload on url change + const oldId = this.props.params.id; + const newId = nextProps.params.id; + if (oldId !== newId) { + store.fetchDocument(newId); + } + // Scroll to anchor after loading, and only once const { hash } = this.props.location; @@ -43,6 +56,19 @@ class DocumentScene extends React.Component { }; } + renderNode = (node) => { + return ( + + {node.module.name} + + ); + } + + handleChange = (tree) => { + console.log(tree); + store.updateNavigationTree(tree); + } + render() { const doc = store.document; let title; @@ -51,6 +77,11 @@ class DocumentScene extends React.Component { if (doc) { actions = (
+ { store.isAtlas ? ( + + New document + + ) : null } Edit @@ -74,13 +105,30 @@ class DocumentScene extends React.Component { titleText={ titleText } actions={ actions } > - - { store.isFetching ? ( + { store.isFetching ? ( + - ) : ( - - ) } - + + ) : ( + + { store.isAtlas ? ( +
+ +
+ ) : null } + + + + + +
+ ) } ); } diff --git a/src/scenes/DocumentScene/DocumentScene.scss b/src/scenes/DocumentScene/DocumentScene.scss index c7d614611..2a9b34235 100644 --- a/src/scenes/DocumentScene/DocumentScene.scss +++ b/src/scenes/DocumentScene/DocumentScene.scss @@ -1,4 +1,9 @@ .actions { display: flex; flex-direction: row; -} \ No newline at end of file +} + +.sidebar { + width: 250px; + padding: 40px 20px; +} diff --git a/src/scenes/DocumentScene/DocumentSceneStore.js b/src/scenes/DocumentScene/DocumentSceneStore.js index e04e9f80d..8d6eb17fd 100644 --- a/src/scenes/DocumentScene/DocumentSceneStore.js +++ b/src/scenes/DocumentScene/DocumentSceneStore.js @@ -1,4 +1,4 @@ -import { observable, action } from 'mobx'; +import { observable, action, computed } from 'mobx'; import { client } from 'utils/ApiClient'; import { browserHistory } from 'react-router'; @@ -8,6 +8,13 @@ const store = new class DocumentSceneStore { @observable isFetching = true; @observable isDeleting; + /* Computed */ + + @computed get isAtlas() { + return this.document && + this.document.atlas.type === 'atlas'; + } + /* Actions */ @action fetchDocument = async (id) => { @@ -35,6 +42,20 @@ const store = new class DocumentSceneStore { } this.isFetching = false; } + + @action updateNavigationTree = async (tree) => { + this.isFetching = true; + + try { + const res = await client.post('/atlases.updateNavigationTree', { + id: this.document.atlas.id, + tree: tree, + }); + } catch (e) { + console.error("Something went wrong"); + } + this.isFetching = false; + } }(); export default store; \ No newline at end of file diff --git a/src/utils/markdown.js b/src/utils/markdown.js index 475e70a10..f6bc68493 100644 --- a/src/utils/markdown.js +++ b/src/utils/markdown.js @@ -16,10 +16,8 @@ renderer.heading = (text, level) => { const headingSlug = slug(text); return ` - -   - ${text} + # `; },