From a6ccda0fbc4df683f9568d85eb22b21684d2a0bd Mon Sep 17 00:00:00 2001 From: Rubén Beltrán del Río Date: Mon, 30 Jan 2017 02:47:14 -0600 Subject: Create and Show Posts (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📝 Update changelog (retroactive) SORRY * Add JSDoc * Document twitter helper * Add posts handler * Remove cookies, set routes under /api * Update readme for clearer callback instructions * Redirect callback to login with token * Add dasein paw helper * Remove unused hostname * Remove hostname, add expiration to config * Rename expiration ttl * Send TTL to posts handler * Add redis dependency * Add redis config to config file * Add DASEIN_REDIS_HOST to env.dist * Correct redis config structure * Add redis db to compose * Add redis to posts handler * Update dasein paw file * Git add uuid * Add first iteration of post handlers * Ignore docs in linter * Ignore generated assets * Ignore docs and assets * Add frontend dependencies + more koa * Add post creation to backend * Adjust readme to show actual callback route * Add npm build as part of docker process * Configure babel * Update paw * Add webpack config * Add frontend code * List globals --- app/.eslintrc | 5 +++ app/components/login.js | 46 +++++++++++++++++++++++ app/components/post_form.js | 57 ++++++++++++++++++++++++++++ app/components/posts.js | 77 ++++++++++++++++++++++++++++++++++++++ app/components/welcome.js | 39 ++++++++++++++++++++ app/dasein.js | 90 +++++++++++++++++++++++++++++++++++++++++++++ app/filters/datetime.js | 10 +++++ app/filters/usertime.js | 20 ++++++++++ app/services/auth.js | 75 +++++++++++++++++++++++++++++++++++++ 9 files changed, 419 insertions(+) create mode 100644 app/.eslintrc create mode 100644 app/components/login.js create mode 100644 app/components/post_form.js create mode 100644 app/components/posts.js create mode 100644 app/components/welcome.js create mode 100644 app/dasein.js create mode 100644 app/filters/datetime.js create mode 100644 app/filters/usertime.js create mode 100644 app/services/auth.js (limited to 'app') diff --git a/app/.eslintrc b/app/.eslintrc new file mode 100644 index 0000000..bd14a19 --- /dev/null +++ b/app/.eslintrc @@ -0,0 +1,5 @@ +{ + "parserOptions": { + "sourceType": "module" + } +} diff --git a/app/components/login.js b/app/components/login.js new file mode 100644 index 0000000..d900c72 --- /dev/null +++ b/app/components/login.js @@ -0,0 +1,46 @@ +import Vue from 'vue'; +import AuthService from '../services/auth'; + +const internals = {}; + +export default internals.LoginComponent = Vue.component('login', { + template: '
' + + '

{{message}}

' + + '
', + + props: ['oAuthToken', 'oAuthVerifier'], + + data() { + + return { + message: 'Logging you in... Wait a sec.', + authService: new AuthService() + }; + }, + methods: { + login() { + + if (this.authService.authenticated) { + return this.$router.push('/'); + } + + return this.authService.getUserObject(this.oAuthToken, this.oAuthVerifier).then((response) => { + + console.log(response); + + return this.authService.login(response.user, response.token, response.expiresAt); + }).then(() => { + + this.$router.push('/'); + }).catch((err) => { + + console.error(err); + this.message = 'Oh no! There was a problem logging you in.'; + }); + } + }, + mounted: function mounted() { + + this.login(); + } +}); diff --git a/app/components/post_form.js b/app/components/post_form.js new file mode 100644 index 0000000..cee17b0 --- /dev/null +++ b/app/components/post_form.js @@ -0,0 +1,57 @@ +import Axios from 'axios'; +import Vue from 'vue'; +import AuthService from '../services/auth'; + +const internals = {}; + +internals.kPostsRoute = '/api/posts'; + +export default internals.PostFormComponent = Vue.component('post-form', { + template: '
' + + '

sup.

' + + '' + + '

{{message}}

' + + '' + + '
', + + data() { + + return { + content: '', + message: '', + submitting: false, + authService: new AuthService() + }; + }, + + methods: { + submit() { + + this.submitting = true; + + return Axios({ + method: 'post', + headers: { + Authorization: `Bearer ${this.authService.token}` + }, + data: { + content: this.content + }, + url: internals.kPostsRoute + }).then((response) => { + + this.$emit('post-submitted', response.data); + this.content = ''; + this.message = ''; + this.submitting = false; + }).catch((err) => { + + console.error(err.stack); + this.submitting = false; + this.message = 'Error while creating the post...'; + }); + } + } +}); + + diff --git a/app/components/posts.js b/app/components/posts.js new file mode 100644 index 0000000..c41707f --- /dev/null +++ b/app/components/posts.js @@ -0,0 +1,77 @@ +import Axios from 'axios'; +import Vue from 'vue'; +import AuthService from '../services/auth'; + +import PostFormComponent from './post_form'; +import DatetimeFilter from '../filters/datetime'; +import UsertimeFilter from '../filters/usertime'; + +const internals = {}; + +internals.kPostsRoute = '/api/posts'; + +export default internals.PostsComponent = Vue.component('posts', { + template: '
' + + '' + + '

Posts.

' + + '

{{message}}

' + + '
' + + '' + + '
{{post.content}}
' + + '
' + + '
', + + data() { + + return { + message: '', + authService: new AuthService(), + posts: [] + }; + }, + + methods: { + fetchPosts() { + + return Axios({ + method: 'get', + headers: { + Authorization: `Bearer ${this.authService.token}` + }, + url: internals.kPostsRoute + }).then((response) => { + + this.posts = response.data; + }).catch((err) => { + + console.err(err.stack); + this.message = 'Error while loading the posts...'; + }); + }, + + addPost(post) { + + this.posts.unshift(post); + } + }, + + components: { + postForm: PostFormComponent, + datetime: DatetimeFilter, + usertime: UsertimeFilter + }, + + mounted: function mounted() { + + if (!this.authService.authenticated) { + return this.$router.push('/login'); + } + + return this.fetchPosts(); + } +}); + diff --git a/app/components/welcome.js b/app/components/welcome.js new file mode 100644 index 0000000..4fcee0f --- /dev/null +++ b/app/components/welcome.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import AuthService from '../services/auth'; + +/* global window */ + +const internals = {}; + +export default internals.WelcomeComponent = Vue.component('welcome', { + template: '
' + + '

{{message}}

' + + 'Login' + + '
', + + data() { + + return { + message: 'Welcome to Dasein, a social network with posts that disappear when the conversation stops', + loggingIn: false, + authService: new AuthService() + }; + }, + + methods: { + initiateLogin() { + + this.message = 'Logging you in...'; + this.loggingIn = true; + + this.authService.initiateLogin().then((authData) => { + + window.location.href = authData.loginUrl; + }).catch((err) => { + + console.error(err); + this.message = 'Oh no! There was a problem logging you in.'; + }); + } + } +}); diff --git a/app/dasein.js b/app/dasein.js new file mode 100644 index 0000000..6b3fd95 --- /dev/null +++ b/app/dasein.js @@ -0,0 +1,90 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; + +import AuthService from './services/auth'; + +import LoginComponent from './components/login'; +import WelcomeComponent from './components/welcome'; +import PostsComponent from './components/posts'; + +/* global window */ + +const internals = {}; + +export default internals.Dasein = { + + start() { + + this._setupVue(); + + internals.authService = new AuthService(); + + this.vm = new Vue({ + router: this._setupRouter(), + el: '#dasein', + components: { + login: LoginComponent, + welcome: WelcomeComponent + }, + data: { + message: 'Hello Vue!' + }, + methods: { + authenticated() { + + return internals.authService.authenticated; + } + } + }); + + }, + + // Initializes vue options + + _setupVue() { + + Vue.use(VueRouter); + }, + + // Sets up the routes + + _setupRouter() { + + const routes = [ + { + path: '/login', + component: WelcomeComponent + }, + { + path: '/', + component: PostsComponent + }, + { + path: '/login-callback', + component: LoginComponent, + props: (route) => { + + return { + oAuthToken: route.query.oauth_token, + oAuthVerifier: route.query.oauth_verifier + }; + } + } + ]; + + const router = new VueRouter({ + mode: 'history', + routes + }); + + return router; + } +}; + + +internals.run = function () { + + internals.Dasein.start(); +}; + +window.addEventListener('load', internals.run); diff --git a/app/filters/datetime.js b/app/filters/datetime.js new file mode 100644 index 0000000..e4bf432 --- /dev/null +++ b/app/filters/datetime.js @@ -0,0 +1,10 @@ +import Vue from 'vue'; + +const internals = {}; + +export default internals.DatetimeFilter = Vue.filter('datetime', (timestamp) => { + + const date = new Date(parseInt(timestamp)); + + return date.toISOString(); +}); diff --git a/app/filters/usertime.js b/app/filters/usertime.js new file mode 100644 index 0000000..de67894 --- /dev/null +++ b/app/filters/usertime.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; + +const internals = {}; + +export default internals.UsertimeFilter = Vue.filter('usertime', (timestamp) => { + + const date = new Date(parseInt(timestamp)); + + const dateString = date.toISOString(); + + const dateComponents = dateString.split('T'); + const dateFragment = dateComponents[0]; + const timeComponents = dateComponents[1].split(':'); + + const hourFragment = timeComponents[0]; + const minuteFragment = timeComponents[1]; + + return `${dateFragment} ${hourFragment}:${minuteFragment}`; +}); + diff --git a/app/services/auth.js b/app/services/auth.js new file mode 100644 index 0000000..32db321 --- /dev/null +++ b/app/services/auth.js @@ -0,0 +1,75 @@ +import Axios from 'axios'; + +const internals = {}; + +/* global localStorage */ + +internals.kInitiateLoginRoute = '/api/auth/login'; +internals.kHandleCallbackRoute = '/api/auth/callback'; + +export default internals.AuthService = class AuthService { + + constructor() { + + this.authenticated = false; + + // Bootstrap from local storage. + if (localStorage.hasOwnProperty('user') && localStorage.hasOwnProperty('token') + && localStorage.hasOwnProperty('expiresAt')) { + + if (parseInt(localStorage.getItem('expiresAt')) > Date.now()) { + this.expiresAt = localStorage.getItem('expiresAt'); + this.user = JSON.parse(localStorage.getItem('user')); + this.token = localStorage.getItem('token'); + this.authenticated = true; + } + } + } + + // Initiates the login process. Resolves to an object that contains the URL + // to redirect to to continue processing. + initiateLogin() { + + return Axios.get(internals.kInitiateLoginRoute).then((response) => { + + return response.data; + }); + } + + // Posts the oAuthToken and verifier to the API to get back a user object + // and a signed JWT + getUserObject(oAuthToken, oAuthVerifier) { + + return Axios.post(internals.kHandleCallbackRoute, { + oAuthToken, + oAuthVerifier + }).then((response) => { + + return response.data; + }); + } + + // Logs in the user by setting the user and token + login(user, token, expiresAt) { + + localStorage.setItem('user', JSON.stringify(user)); + localStorage.setItem('token', token); + localStorage.setItem('expiresAt', expiresAt); + this.user = user; + this.token = token; + this.expiresAt = expiresAt; + this.authenticated = true; + } + + // Logs a user out by removing the user and token + logout() { + + localStorage.removeItem('user'); + localStorage.removeItem('token'); + localStorage.removeItem('expiresAt'); + delete this.user; + delete this.token; + delete this.expiresAt; + this.authenticated = false; + } +}; -- cgit From 9b1f50fefe021578113b8f5935084fe4fed110b2 Mon Sep 17 00:00:00 2001 From: Rubén Beltrán del Río Date: Tue, 31 Jan 2017 00:49:26 -0600 Subject: Add Comments (#4) * Preserve whitespace in posts * Create comments handler * Use comments handler * Update paw * Add comment components * Style comments * Remove unnecessary code from app * Fix linter warning * Add key to handle children (thx @javierbyte) * Add toggling to the form * Return all keys always * Correct missing semicolons * Restore redirect * Render posts conditionally * Set poll time to 1s * Set post frequency to 2s * Add polling to comments * Correct typo in error logging * Clear intervals and reduce polling * Stops polling if it's being destroyed * Update changelog --- CHANGELOG.md | 3 + app/components/comment.js | 17 +++++ app/components/comment_form.js | 73 +++++++++++++++++++++ app/components/comments.js | 76 ++++++++++++++++++++++ app/components/post.js | 23 +++++++ app/components/post_form.js | 19 +++++- app/components/posts.js | 24 +++---- app/dasein.js | 7 --- etc/dasein.paw | Bin 16889 -> 20249 bytes lib/dasein.js | 14 +++++ lib/handlers/comments.js | 140 +++++++++++++++++++++++++++++++++++++++++ lib/handlers/posts.js | 14 +++-- static/css/app.css | 48 ++++++++++++++ 13 files changed, 430 insertions(+), 28 deletions(-) create mode 100644 app/components/comment.js create mode 100644 app/components/comment_form.js create mode 100644 app/components/comments.js create mode 100644 app/components/post.js create mode 100644 lib/handlers/comments.js (limited to 'app') 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: '
' + + '' + + '
{{comment.content}}
' + + '
', + + 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: '
' + + '

' + + '' + + '

' + + '' + + '

{{message}}

' + + '' + + '
', + + 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: '
' + + '

{{message}}

' + + '' + + '' + + '
', + + 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: '
' + + '' + + '
{{post.content}}
' + + '' + + '
', + + 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: '
' + - '

sup.

' + - '' + + '

hi {{authService.user.name}}

' + + '' + + '' + '

{{message}}

' + - '' + + '' + '
', 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: '
' + + template: '
' + '' + '

Posts.

' + '

{{message}}

' + - '
' + - '' + - '
{{post.content}}
' + - '
' + + '' + '
', 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 index 9983dad..632b02b 100644 Binary files a/etc/dasein.paw and b/etc/dasein.paw differ 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; +} -- cgit