diff options
| -rw-r--r-- | CHANGELOG.md | 3 | ||||
| -rw-r--r-- | app/components/comment.js | 17 | ||||
| -rw-r--r-- | app/components/comment_form.js | 73 | ||||
| -rw-r--r-- | app/components/comments.js | 76 | ||||
| -rw-r--r-- | app/components/post.js | 23 | ||||
| -rw-r--r-- | app/components/post_form.js | 19 | ||||
| -rw-r--r-- | app/components/posts.js | 24 | ||||
| -rw-r--r-- | app/dasein.js | 7 | ||||
| -rw-r--r-- | etc/dasein.paw | bin | 16889 -> 20249 bytes | |||
| -rw-r--r-- | lib/dasein.js | 14 | ||||
| -rw-r--r-- | lib/handlers/comments.js | 140 | ||||
| -rw-r--r-- | lib/handlers/posts.js | 14 | ||||
| -rw-r--r-- | static/css/app.css | 48 |
13 files changed, 430 insertions, 28 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 425a9d3..fd38475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] - 2016-12-12 ### Added +- Add inefficient API response +- Add inefficient polling +- Add comments - JSDoc config - Twitter OAuth Integration - Simple contributing guidelines diff --git a/app/components/comment.js b/app/components/comment.js new file mode 100644 index 0000000..33ce2c7 --- /dev/null +++ b/app/components/comment.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; + +const internals = {}; + +export default internals.CommentComponent = Vue.component('comment', { + template: '<article class="comment">' + + '<aside class="comment-meta">' + + '<img :src="comment.userImage" v-bind:alt="\'Avatar for @\' + comment.userId">' + + '<a v-bind:href="\'https://twitter.com/\' + comment.userId">{{comment.userName}}</a> said on ' + + '<time v-bind:datetime="comment.timestamp | datetime">{{comment.timestamp | usertime}}</time>' + + '</aside>' + + '<div class="comment-content">{{comment.content}}</div>' + + '</article>', + + props: ['comment'] +}); + diff --git a/app/components/comment_form.js b/app/components/comment_form.js new file mode 100644 index 0000000..bf60d06 --- /dev/null +++ b/app/components/comment_form.js @@ -0,0 +1,73 @@ +import Axios from 'axios'; +import Vue from 'vue'; +import AuthService from '../services/auth'; + +const internals = {}; + +internals.kPostsRoute = '/api/posts'; +internals.kCommentsRoute = '/comments'; + +export default internals.CommentFormComponent = Vue.component('comment-form', { + template: '<div class="comment-form-container">' + + '<p v-show="!active" class="comment-form-error">' + + '<button class="comment-activate" v-on:click="activate">Add comment.</button>' + + '</p>' + + '<textarea v-show="active" :disabled="submitting" v-model="content" class="comment-content-input" placeholder="tell us something" maxlength=255></textarea>' + + '<p v-show="message" class="comment-form-error">{{message}}</p>' + + '<button v-show="active" :disabled="submitting" class="comment-submit" v-on:click="submit">Go.</button>' + + '</div>', + + props: ['postUuid'], + + data() { + + return { + content: '', + message: '', + active: false, + submitting: false, + authService: new AuthService() + }; + }, + + methods: { + + // Activates the form. + + activate() { + + this.active = true; + }, + + // Creates a comment + + submit() { + + this.submitting = true; + const route = `${internals.kPostsRoute}/${this.postUuid}${internals.kCommentsRoute}`; + + return Axios({ + method: 'post', + headers: { + Authorization: `Bearer ${this.authService.token}` + }, + data: { + content: this.content + }, + url: route + }).then((response) => { + + this.$emit('comment-submitted', response.data); + this.content = ''; + this.message = ''; + this.submitting = false; + this.active = false; + }).catch((err) => { + + console.error(err.stack); + this.submitting = false; + this.message = 'Error while creating the post...'; + }); + } + } +}); diff --git a/app/components/comments.js b/app/components/comments.js new file mode 100644 index 0000000..e29cedc --- /dev/null +++ b/app/components/comments.js @@ -0,0 +1,76 @@ +import Axios from 'axios'; +import Vue from 'vue'; +import AuthService from '../services/auth'; + +import CommentFormComponent from './comment_form'; +import CommentComponent from './comment'; +import DatetimeFilter from '../filters/datetime'; +import UsertimeFilter from '../filters/usertime'; + +const internals = {}; + +internals.kPostsRoute = '/api/posts'; +internals.kCommentsRoute = '/comments'; +internals.kPollFrequency = 2000; + +export default internals.CommentsComponent = Vue.component('comments', { + template: '<div class="comments-container">' + + '<p v-show="message" class="comments-error">{{message}}</p>' + + '<comment v-for="comment in comments" v-bind:comment="comment" :key="comment.uuid"></comment>' + + '<comment-form v-bind:postUuid="postUuid" v-on:comment-submitted="addComment"></comment-form>' + + '</div>', + + props: ['postUuid'], + + data() { + + return { + message: '', + poller: null, + authService: new AuthService(), + comments: [] + }; + }, + + methods: { + fetchComments() { + + const route = `${internals.kPostsRoute}/${this.postUuid}${internals.kCommentsRoute}`; + + return Axios({ + method: 'get', + headers: { + Authorization: `Bearer ${this.authService.token}` + }, + url: route + }).then((response) => { + + this.comments = response.data; + if (!this._isBeingDestroyed) { + setTimeout(this.fetchComments.bind(this), internals.kPollFrequency); + } + }).catch((err) => { + + console.error(err.stack); + this.message = 'Error while loading the comments...'; + }); + }, + + addComment(comment) { + + this.comments.push(comment); + } + }, + + components: { + commentForm: CommentFormComponent, + comment: CommentComponent, + datetime: DatetimeFilter, + usertime: UsertimeFilter + }, + + mounted() { + + this.fetchComments(); + } +}); diff --git a/app/components/post.js b/app/components/post.js new file mode 100644 index 0000000..bb72189 --- /dev/null +++ b/app/components/post.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; + +import CommentsComponent from './comments'; + +const internals = {}; + +export default internals.PostComponent = Vue.component('post', { + template: '<article class="post">' + + '<aside class="post-meta">' + + '<img :src="post.userImage" v-bind:alt="\'Avatar for @\' + post.userId">' + + '<a v-bind:href="\'https://twitter.com/\' + post.userId">{{post.userName}}</a> said on ' + + '<time v-bind:datetime="post.timestamp | datetime">{{post.timestamp | usertime}}</time>' + + '</aside>' + + '<div class="post-content">{{post.content}}</div>' + + '<comments v-bind:postUuid="post.uuid"></post>' + + '</article>', + + props: ['post'], + + components: { + comments: CommentsComponent + } +}); diff --git a/app/components/post_form.js b/app/components/post_form.js index cee17b0..1f45f0c 100644 --- a/app/components/post_form.js +++ b/app/components/post_form.js @@ -8,10 +8,11 @@ internals.kPostsRoute = '/api/posts'; export default internals.PostFormComponent = Vue.component('post-form', { template: '<div class="post-form-container">' + - '<h1>sup.</h1>' + - '<textarea :disabled="submitting" v-model="content" class="post-content-input" placeholder="tell us something" maxlength=255></textarea>' + + '<h1>hi {{authService.user.name}}</h1>' + + '<button v-show="!active" class="post-submit" v-on:click="activate">Post something.</button>' + + '<textarea v-show="active" :disabled="submitting" v-model="content" class="post-content-input" placeholder="tell us something" maxlength=255></textarea>' + '<p v-show="message" class="post-form-error">{{message}}</p>' + - '<button :disabled="submitting" class="post-submit" v-on:click="submit">Go.</button>' + + '<button v-show="active" :disabled="submitting" class="post-submit" v-on:click="submit">Go.</button>' + '</div>', data() { @@ -20,11 +21,22 @@ export default internals.PostFormComponent = Vue.component('post-form', { content: '', message: '', submitting: false, + active: false, authService: new AuthService() }; }, methods: { + + // Activates the form. + + activate() { + + this.active = true; + }, + + // Creates a post + submit() { this.submitting = true; @@ -44,6 +56,7 @@ export default internals.PostFormComponent = Vue.component('post-form', { this.content = ''; this.message = ''; this.submitting = false; + this.active = false; }).catch((err) => { console.error(err.stack); diff --git a/app/components/posts.js b/app/components/posts.js index c41707f..d0e0338 100644 --- a/app/components/posts.js +++ b/app/components/posts.js @@ -3,26 +3,21 @@ import Vue from 'vue'; import AuthService from '../services/auth'; import PostFormComponent from './post_form'; +import PostComponent from './post'; import DatetimeFilter from '../filters/datetime'; import UsertimeFilter from '../filters/usertime'; const internals = {}; internals.kPostsRoute = '/api/posts'; +internals.kPollFrequency = 5000; export default internals.PostsComponent = Vue.component('posts', { - template: '<div class="posts-container">' + + template: '<div v-if="authService.authenticated" class="posts-container">' + '<post-form v-on:post-submitted="addPost"></post-form>' + '<h1>Posts.</h1>' + '<p v-show="message" class="posts-error">{{message}}</p>' + - '<article class="post" v-for="post in posts">' + - '<aside class="post-meta">' + - '<img :src="post.userImage" v-bind:alt="\'Avatar for @\' + post.userId">' + - '<a v-bind:href="\'https://twitter.com/\' + post.userId">{{post.userName}}</a> said on ' + - '<time v-bind:datetime="post.timestamp | datetime">{{post.timestamp | usertime}}</time>' + - '</aside>' + - '<div class="post-content">{{post.content}}</div>' + - '</article>' + + '<post v-for="post in posts" v-bind:post="post" :key="post.uuid"></post>' + '</div>', data() { @@ -46,9 +41,12 @@ export default internals.PostsComponent = Vue.component('posts', { }).then((response) => { this.posts = response.data; + if (!this._isBeingDestroyed) { + setTimeout(this.fetchPosts.bind(this), internals.kPollFrequency); + } }).catch((err) => { - console.err(err.stack); + console.error(err.stack); this.message = 'Error while loading the posts...'; }); }, @@ -61,17 +59,19 @@ export default internals.PostsComponent = Vue.component('posts', { components: { postForm: PostFormComponent, + post: PostComponent, datetime: DatetimeFilter, usertime: UsertimeFilter }, - mounted: function mounted() { + mounted() { if (!this.authService.authenticated) { return this.$router.push('/login'); } - return this.fetchPosts(); + this.fetchPosts(); + } }); diff --git a/app/dasein.js b/app/dasein.js index 6b3fd95..699a01f 100644 --- a/app/dasein.js +++ b/app/dasein.js @@ -22,13 +22,6 @@ export default internals.Dasein = { this.vm = new Vue({ router: this._setupRouter(), el: '#dasein', - components: { - login: LoginComponent, - welcome: WelcomeComponent - }, - data: { - message: 'Hello Vue!' - }, methods: { authenticated() { diff --git a/etc/dasein.paw b/etc/dasein.paw Binary files differindex 9983dad..632b02b 100644 --- a/etc/dasein.paw +++ b/etc/dasein.paw diff --git a/lib/dasein.js b/lib/dasein.js index 3a537bd..5b8486a 100644 --- a/lib/dasein.js +++ b/lib/dasein.js @@ -10,6 +10,7 @@ const Path = require('path'); const AuthHandler = require('./handlers/auth'); const PostsHandler = require('./handlers/posts'); +const CommentsHandler = require('./handlers/comments'); const internals = {}; @@ -90,6 +91,7 @@ module.exports = internals.Dasein = class Dasein { this._initializeAuthRoutes(); this._initializePostsRoutes(); + this._initializeCommentsRoutes(); this._app.use(function * () { @@ -125,6 +127,18 @@ module.exports = internals.Dasein = class Dasein { } + // Initialize routes for comments + + _initializeCommentsRoutes() { + + const commentsHandler = new CommentsHandler({ + ttl: this.ttl, + redis: this.redis + }); + this._app.use(KoaRoute.get('/api/posts/:postId/comments', commentsHandler.findAll())); + this._app.use(KoaRoute.post('/api/posts/:postId/comments', commentsHandler.create())); + } + // Starts listening _startServer() { diff --git a/lib/handlers/comments.js b/lib/handlers/comments.js new file mode 100644 index 0000000..14bd137 --- /dev/null +++ b/lib/handlers/comments.js @@ -0,0 +1,140 @@ +'use strict'; + +const Joi = require('joi'); +const Pify = require('pify'); +const Redis = require('redis'); +const UUID = require('uuid/v4'); + +const internals = {}; + +internals.kPostsPrefix = 'posts'; +internals.kCommentsPrefix = 'comments'; +internals.kMaxCommentSize = 255; + +internals.kCommentsSchema = Joi.object().keys({ + uuid: Joi.string().required(), + content: Joi.string().max(internals.kMaxCommentSize).required(), + timestamp: Joi.number().integer().required(), + userId: Joi.string().required(), + userName: Joi.string().required(), + userImage: Joi.string().required() +}); + +/** + * Handles the HTTP requests for comment related operations + * + * @class CommentsHandler + * @param {Dasein.tConfiguration} config The configuration to + * initialize. + */ +module.exports = internals.CommentsHandler = class CommentsHandler { + constructor(config) { + + this._ttl = config.ttl; + this._redis = Redis.createClient(config.redis); + + // Log an error if it happens. + this._redis.on('error', (err) => { + + console.error(err); + }); + } + + /** + * Fetches all available comments + * + * @function findAll + * @memberof CommentsHandler + * @instance + * @return {generator} a koa compatible handler generator function + */ + findAll() { + + const self = this; + + return function * (postId) { + + if (!this.state.user) { + return this.throw('Unauthorized', 401); + } + + const scan = Pify(self._redis.scan.bind(self._redis)); + const hgetall = Pify(self._redis.hgetall.bind(self._redis)); + + const commentsKey = `${internals.kCommentsPrefix}:${postId}:*`; + let keys = []; + let nextCursor = 0; + let currentKeys = null; + + do { + [nextCursor, currentKeys] = yield scan(nextCursor || 0, 'MATCH', commentsKey); + keys = keys.concat(currentKeys); + } while (nextCursor > 0); + + const comments = yield keys.map((key) => hgetall(key)); + + this.body = comments.sort((a, b) => a.timestamp - b.timestamp); + }; + } + + /** + * Creates a comment + * + * @function create + * @memberof CommentsHandler + * @instance + * @return {generator} a koa compatible handler generator function + */ + create() { + + const self = this; + + return function * (postId) { + + if (!this.state.user) { + return this.throw('Unauthorized', 401); + } + + const hmset = Pify(self._redis.hmset.bind(self._redis)); + const hgetall = Pify(self._redis.hgetall.bind(self._redis)); + const expire = Pify(self._redis.expire.bind(self._redis)); + + const uuid = UUID(); + const timestamp = Date.now(); + const user = this.state.user; + + const postKey = `${internals.kPostsPrefix}:${postId}`; + const commentKey = `${internals.kCommentsPrefix}:${postId}:${uuid}`; + + const comment = { + uuid, + content: this.request.body.content, + timestamp, + userId: user.screen_name, + userName: user.name, + userImage: user.profile_image_url_https + }; + + yield self._validate(comment).catch((err) => { + + this.throw(err.message, 422); + }); + + yield hmset(commentKey, comment); + yield expire(commentKey, self._ttl * 100); // this is me being lazy :( + // comments will last at most 100 bumps + // but will disappear eventually + yield expire(postKey, self._ttl); // bumps the parent comment TTL + + this.body = yield hgetall(commentKey); + }; + } + + // Validates the comment schema + + _validate(comment) { + + const validate = Pify(Joi.validate.bind(Joi)); + return validate(comment, internals.kCommentsSchema); + } +}; diff --git a/lib/handlers/posts.js b/lib/handlers/posts.js index 425fb5e..b5e4f0e 100644 --- a/lib/handlers/posts.js +++ b/lib/handlers/posts.js @@ -60,16 +60,18 @@ module.exports = internals.PostsHandler = class PostsHandler { const scan = Pify(self._redis.scan.bind(self._redis)); const hgetall = Pify(self._redis.hgetall.bind(self._redis)); - const cursor = parseInt(this.request.query.cursor) || 0; - const [nextCursor, keys] = yield scan(cursor, 'MATCH', `${internals.kPostsPrefix}:*`); + let keys = []; + let nextCursor = 0; + let currentKeys = null; - if (nextCursor > 0) { - this.append('Link', `<${this.request.origin}${this.request.path}?cursor=${nextCursor}>; rel="next"`); - } + do { + [nextCursor, currentKeys] = yield scan(nextCursor || 0, 'MATCH', `${internals.kPostsPrefix}:*`); + keys = keys.concat(currentKeys); + } while (nextCursor > 0); const posts = yield keys.map((key) => hgetall(key)); - this.body = posts; + this.body = posts.sort((a, b) => b.timestamp - a.timestamp); }; } diff --git a/static/css/app.css b/static/css/app.css index 34712e3..2177c51 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -47,16 +47,22 @@ section { /* The stream */ +.comments-container .comment .comment-meta, .posts-container .post .post-meta { background-color: lightgray; display: flex; align-items: center; + font-size: 20px; } +.comment-meta time, .comment-meta a, .post-meta time, .post-meta a { padding: 0 10px; } +.comments-container .comment .comment-content, +.comment-form-container .comment-content-input, +.comment-form-container .comment-submit, .posts-container .post .post-content, .post-form-container .post-content-input, .post-form-container .post-submit { @@ -68,6 +74,13 @@ section { padding: 10px; } +.comments-container .comment .comment-content, +.posts-container .post .post-content { + white-space: pre-wrap; + word-wrap: break-word; +} + +.comment-form-container .comment-content-input, .post-form-container .post-content-input { display: block; margin: 10px; @@ -76,6 +89,8 @@ section { height: 140px; } +.comment-form-container .comment-submit, +.comment-form-container .comment-activate, .post-form-container .post-submit { margin: 10px auto; display: block; @@ -87,7 +102,40 @@ section { } .posts-error, +.comments-error, .post-form-error { color: red; padding: 10px; } + +/* Comments */ + +.comments-container .comment .comment-meta { + background-color: ghostwhite; + font-size: 16px; +} + +.comments-container .comment .comment-meta img { + width: 24px; + height: 24px; +} + +.comments-container .comment .comment-content, +.comment-form-container .comment-content-input, +.comment-form-container .comment-submit { + font-size: 16px; +} + +.comment-form-container .comment-activate { + padding: 2px; + font-size: 12px; +} + +.comment-form-container .comment-content-input { + height: 70px; +} + +.comment-form-container .comment-activate:active, +.comment-form-container .comment-submit:active { + background-color: magenta; +} |