diff --git a/app/controllers/admin/newsletter.js b/app/controllers/admin/newsletter.js index 1b58ae7..9eaf061 100644 --- a/app/controllers/admin/newsletter.js +++ b/app/controllers/admin/newsletter.js @@ -66,10 +66,10 @@ class NewsletterController extends SiteController { async postCreateNewsletter (req, res, next) { const { newsletter: newsletterService } = this.dtp.services; try { - const newsletter = await newsletterService.create(req.user, req.body); + await newsletterService.create(req.user, req.body); res.redirect('/admin/newsletter'); } catch (error) { - this.log.error('failed to update newsletter', { error }); + this.log.error('failed to create newsletter', { error }); return next(error); } } @@ -89,7 +89,7 @@ class NewsletterController extends SiteController { } } - async deleteNewsletter (req, res, next) { + async deleteNewsletter (req, res) { const { newsletter: newsletterService, displayEngine: displayEngineService } = this.dtp.services; try { const displayList = displayEngineService.createDisplayList('delete-newsletter'); diff --git a/app/controllers/admin/post.js b/app/controllers/admin/post.js index 864a2e2..7f0004b 100644 --- a/app/controllers/admin/post.js +++ b/app/controllers/admin/post.js @@ -7,7 +7,7 @@ const DTP_COMPONENT_NAME = 'admin:post'; const express = require('express'); -const { SiteController } = require('../../../lib/site-lib'); +const { SiteController, SiteError } = require('../../../lib/site-lib'); class PostController extends SiteController { @@ -25,17 +25,94 @@ class PostController extends SiteController { router.param('postId', this.populatePostId.bind(this)); + router.post('/:postId', this.postUpdatePost.bind(this)); + router.post('/', this.postCreatePost.bind(this)); + + router.get('/compose', this.getComposer.bind(this)); + router.get('/:postId', this.getComposer.bind(this)); + router.get('/', this.getIndex.bind(this)); return router; } - async populatePostId (req, res, next/*, postId*/) { - return next(); + async populatePostId (req, res, next, postId) { + const { post: postService } = this.dtp.services; + try { + res.locals.post = await postService.getById(postId); + if (!res.locals.post) { + throw new SiteError(404, 'Post not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate postId', { postId, error }); + return next(error); + } + } + + async postUpdatePost (req, res, next) { + const { post: postService } = this.dtp.services; + try { + await postService.update(res.locals.post, req.body); + res.redirect('/admin/post'); + } catch (error) { + this.log.error('failed to update post', { newletterId: res.locals.post._id, error }); + return next(error); + } + } + + async postCreatePost (req, res, next) { + const { post: postService } = this.dtp.services; + try { + await postService.create(req.user, req.body); + res.redirect('/admin/post'); + } catch (error) { + this.log.error('failed to create post', { error }); + return next(error); + } } - async getIndex (req, res) { - res.render('admin/post/index'); + async getComposer (req, res) { + res.render('admin/post/editor'); + } + + async getIndex (req, res, next) { + const { post: postService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.posts = await postService.getPosts(res.locals.pagination, ['draft', 'published']); + res.render('admin/post/index'); + } catch (error) { + this.log.error('failed to fetch posts', { error }); + return next(error); + } + } + + async deletePost (req, res) { + const { post: postService, displayEngine: displayEngineService } = this.dtp.services; + try { + const displayList = displayEngineService.createDisplayList('delete-post'); + + await postService.deletePost(res.locals.post); + + displayList.removeElement(`li[data-post-id="${res.locals.post._id}"]`); + displayList.showNotification( + `Post "${res.locals.post.title}" deleted`, + 'success', + 'bottom-center', + 3000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to delete post', { + postId: res.local.post._id, + error, + }); + res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } } } diff --git a/app/controllers/home.js b/app/controllers/home.js index f75abb0..78f5982 100644 --- a/app/controllers/home.js +++ b/app/controllers/home.js @@ -35,10 +35,12 @@ class HomeController extends SiteController { } async getHome (req, res, next) { - const { gabTV: gabTvService } = this.dtp.services; + const { gabTV: gabTvService, post: postService } = this.dtp.services; try { - res.locals.pagination = this.getPaginationParameters(req, 20); res.locals.gabTvChannel = await gabTvService.getChannelEpisodes('mrjoeprich'); + + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.posts = await postService.getPosts(res.locals.pagination); res.render('index'); } catch (error) { return next(error); diff --git a/app/controllers/post.js b/app/controllers/post.js new file mode 100644 index 0000000..2226b9a --- /dev/null +++ b/app/controllers/post.js @@ -0,0 +1,103 @@ +// post.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT_NAME = 'post'; + +const express = require('express'); + +const { SiteController, SiteError } = require('../../lib/site-lib'); + +class PostController extends SiteController { + + constructor (dtp) { + super(dtp, DTP_COMPONENT_NAME); + } + + async start ( ) { + const { dtp } = this; + const { limiter: limiterService } = dtp.services; + + const router = express.Router(); + dtp.app.use('/post', router); + + router.use(async (req, res, next) => { + res.locals.currentView = 'home'; + return next(); + }); + + router.param('postSlug', this.populatePostSlug.bind(this)); + + router.get('/:postSlug', + limiterService.create(limiterService.config.post.getView), + this.getView.bind(this), + ); + + router.get('/', + limiterService.create(limiterService.config.post.getIndex), + this.getIndex.bind(this), + ); + } + + async populatePostYear (req, res, next, postYear) { + try { + res.locals.postYear = parseInt(postYear, 10); + if (!res.locals.postYear || isNaN(res.locals.postYear)) { + throw new Error('Invalid post year'); + } + return next(); + } catch (error) { + this.log.error('failed to populate post year', { postYear, error }); + return next(error); + } + } + + async populatePostMonth (req, res, next, postMonth) { + try { + res.locals.postMonth = parseInt(postMonth, 10); + if (!res.locals.postMonth || isNaN(res.locals.postMonth) || (postMonth < 1) || (postMonth > 12)) { + throw new Error('Invalid post month'); + } + return next(); + } catch (error) { + this.log.error('failed to populate post month', { postMonth, error }); + return next(error); + } + } + + async populatePostSlug (req, res, next, postSlug) { + const { post: postService } = this.dtp.services; + try { + res.locals.post = await postService.getBySlug(postSlug); + if (!res.locals.post) { + throw new SiteError(404, 'Post not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate postSlug', { postSlug, error }); + return next(error); + } + } + + async getView (req, res) { + res.render('post/view'); + } + + async getIndex (req, res, next) { + const { post: postService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.posts = await postService.getPosts(res.locals.pagination); + res.render('post/index'); + } catch (error) { + return next(error); + } + } +} + +module.exports = async (dtp) => { + let controller = new PostController(dtp); + return controller; +}; \ No newline at end of file diff --git a/app/models/article.js b/app/models/article.js deleted file mode 100644 index 663ee14..0000000 --- a/app/models/article.js +++ /dev/null @@ -1,23 +0,0 @@ -// article.js -// Copyright (C) 2021 Digital Telepresence, LLC -// License: Apache-2.0 - -'use strict'; - -const mongoose = require('mongoose'); - -const Schema = mongoose.Schema; - -const ArticleSchema = new Schema({ - created: { type: Date, default: Date.now, required: true, index: -1 }, - author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, - image: { type: Schema.ObjectId, required: true, ref: 'Image' }, - title: { type: String, required: true }, - summary: { type: String }, - content: { type: String }, - flags: { - isFeatured: { type: Boolean, default: false, index: true }, - }, -}); - -module.exports = mongoose.model('Article', ArticleSchema); \ No newline at end of file diff --git a/app/models/post.js b/app/models/post.js new file mode 100644 index 0000000..4881602 --- /dev/null +++ b/app/models/post.js @@ -0,0 +1,28 @@ +// post.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const POST_STATUS_LIST = ['draft','published','archived']; + +const PostSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1 }, + updated: { type: Date }, + author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, + image: { type: Schema.ObjectId, ref: 'Image' }, + title: { type: String, required: true }, + slug: { type: String, required: true, lowercase: true, unique: true }, + summary: { type: String, required: true }, + content: { type: String, required: true, select: false }, + status: { type: String, enum: POST_STATUS_LIST, default: 'draft', index: true }, + flags: { + isFeatured: { type: Boolean, default: false, index: true }, + }, +}); + +module.exports = mongoose.model('Post', PostSchema); \ No newline at end of file diff --git a/app/services/article.js b/app/services/article.js deleted file mode 100644 index a3135b4..0000000 --- a/app/services/article.js +++ /dev/null @@ -1,66 +0,0 @@ -// article.js -// Copyright (C) 2021 Digital Telepresence, LLC -// License: Apache-2.0 - -'use strict'; - -const fs = require('fs'); - -const { SiteService } = require('../../lib/site-lib'); - -const mongoose = require('mongoose'); -const Article = mongoose.model('Article'); - -const marked = require('marked'); - -class ArticleService extends SiteService { - - constructor (dtp) { - super(dtp, module.exports); - - this.populateArticle = [ - { - path: 'channel', - select: 'slug name images.icon status stats links', - }, - { - path: 'author', - select: '_id username username_lc displayName picture', - }, - ]; - } - - async start ( ) { - this.markedRenderer = new marked.Renderer(); - } - - async getById (articleId) { - const article = await Article - .findById(articleId) - .populate(this.populateArticle) - .lean(); - return article; - } - - async getForChannel (channel, pagination) { - const articles = await Article - .find({ channel: channel._id }) - .sort({ created: -1 }) - .skip(pagination.skip) - .limit(pagination.cpp) - .populate(this.populateArticle) - .lean(); - return articles; - } - - async renderMarkdown (documentFile) { - const markdown = await fs.promises.readFile(documentFile, 'utf8'); - return marked(markdown, { renderer: this.markedRenderer }); - } -} - -module.exports = { - slug: 'article', - name: 'article', - create: (dtp) => { return new ArticleService(dtp); }, -}; \ No newline at end of file diff --git a/app/services/post.js b/app/services/post.js new file mode 100644 index 0000000..5d3accc --- /dev/null +++ b/app/services/post.js @@ -0,0 +1,125 @@ +// post.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const striptags = require('striptags'); +const slug = require('slug'); + +const { SiteService } = require('../../lib/site-lib'); + +const mongoose = require('mongoose'); + +const Post = mongoose.model('Post'); + +class PostService extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + + this.populatePost = [ + { + path: 'author', + select: '_id username username_lc displayName picture', + }, + ]; + } + + async create (author, postDefinition) { + const NOW = new Date(); + + const post = new Post(); + post.created = NOW; + post.author = author._id; + post.title = striptags(postDefinition.title.trim()); + post.slug = this.createPostSlug(post._id, post.title); + post.summary = striptags(postDefinition.summary.trim()); + post.content = postDefinition.content.trim(); + post.status = 'draft'; + + await post.save(); + + return post.toObject(); + } + + async update (post, postDefinition) { + const NOW = new Date(); + const updateOp = { + $set: { + updated: NOW, + }, + }; + + if (postDefinition.title) { + updateOp.$set.title = striptags(postDefinition.title.trim()); + updateOp.$set.slug = this.createPostSlug(post._id, updateOp.$set.title); + } + if (postDefinition.summary) { + updateOp.$set.summary = striptags(postDefinition.summary.trim()); + } + if (postDefinition.content) { + updateOp.$set.content = postDefinition.content.trim(); + } + if (postDefinition.status) { + updateOp.$set.status = striptags(postDefinition.status.trim()); + } + + if (Object.keys(updateOp.$set).length === 0) { + return; // no update to perform + } + + await Post.updateOne( + { _id: post._id }, + updateOp, + { upsert: true }, + ); + } + + async getPosts (pagination, status = ['published']) { + if (!Array.isArray(status)) { + status = [status]; + } + const posts = await Post + .find({ status: { $in: status } }) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .lean(); + return posts; + } + + async getById (postId) { + const post = await Post + .findById(postId) + .select('+content') + .populate(this.populatePost) + .lean(); + return post; + } + + async getBySlug (postSlug) { + const slugParts = postSlug.split('-'); + const postId = slugParts[slugParts.length - 1]; + return this.getById(postId); + } + + async deletePost (post) { + this.log.info('deleting post', { postId: post._id }); + await Post.deleteOne({ _id: post._id }); + } + + createPostSlug (postId, postTitle) { + if ((typeof postTitle !== 'string') || (postTitle.length < 1)) { + throw new Error('Invalid input for making a post slug'); + } + const postSlug = slug(postTitle.trim().toLowerCase()).split('-').slice(0, 4).join('-'); + return `${postSlug}-${postId}`; + } +} + +module.exports = { + slug: 'post', + name: 'post', + create: (dtp) => { return new PostService(dtp); }, +}; \ No newline at end of file diff --git a/app/views/admin/post/editor.pug b/app/views/admin/post/editor.pug new file mode 100644 index 0000000..ada8afa --- /dev/null +++ b/app/views/admin/post/editor.pug @@ -0,0 +1,67 @@ +extends ../layouts/main +block content + + - var actionUrl = post ? `/admin/post/${post._id}` : `/admin/post`; + + form(method="POST", action= actionUrl).uk-form + div(uk-grid).uk-grid-small + .uk-width-2-3 + .uk-margin + label(for="content").uk-form-label Post body + textarea(id="content", name="content", rows="4").uk-textarea= post ? post.content : undefined + + .uk-width-1-3 + .uk-margin + label(for="title").uk-form-label Post title + input(id="title", name="title", type="text", placeholder= "Enter post title", value= post ? post.title : undefined).uk-input + .uk-margin + label(for="summary").uk-form-label Post summary + textarea(id="summary", name="summary", rows="4", placeholder= "Enter post summary (text only, no HTML)").uk-textarea= post ? post.summary : undefined + div(uk-grid) + .uk-width-auto + button(type="submit").uk-button.dtp-button-primary= post ? 'Update post' : 'Create post' + .uk-width-auto + a(href="/admin/post").uk-button.dtp-button-default Cancel + .uk-margin + label(for="status").uk-form-label Status + select(id="status", name="status").uk-select + option(value="draft", selected= post ? post.status === 'draft' : true) Draft + option(value="published", selected= post ? post.status === 'published' : false) Published + option(value="archived", selected= post ? post.status === 'archived' : true) Archived + +block viewjs + script(src="/tinymce/tinymce.min.js") + script. + window.addEventListener('dtp-load', async ( ) => { + const toolbarItems = [ + 'undo redo', + 'formatselect visualblocks', + 'bold italic backcolor', + 'alignleft aligncenter alignright alignjustify', + 'bullist numlist outdent indent removeformat', + 'link image', + 'help' + ]; + const pluginItems = [ + 'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'print', + 'preview', 'anchor', 'searchreplace', 'visualblocks', 'code', + 'fullscreen', 'insertdatetime', 'media', 'table', 'paste', 'code', + 'help', 'wordcount', + ] + + const editors = await tinymce.init({ + selector: 'textarea#content', + height: 500, + menubar: false, + plugins: pluginItems.join(' '), + toolbar: toolbarItems.join('|'), + branding: false, + images_upload_url: '/image/tinymce', + image_class_list: [ + { title: 'Body Image', value: 'dtp-image-body' }, + { title: 'Title Image', value: 'dtp-image-title' }, + ], + }); + + window.dtp.app.editor = editors[0]; + }); \ No newline at end of file diff --git a/app/views/admin/post/index.pug b/app/views/admin/post/index.pug new file mode 100644 index 0000000..3aab8de --- /dev/null +++ b/app/views/admin/post/index.pug @@ -0,0 +1,45 @@ +extends ../layouts/main +block content + + .uk-margin + div(uk-grid) + .uk-width-expand + h1.uk-text-truncate Posts + .uk-width-auto + a(href="/admin/post/compose").uk-button.dtp-button-primary + +renderButtonIcon('fa-plus', 'New Post') + + .uk-margin + if (Array.isArray(posts) && (posts.length > 0)) + + ul.uk-list + + each post in posts + + li(data-post-id= post._id) + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-expand + a(href=`/post/${post.slug}`).uk-display-block.uk-text-large.uk-text-truncate #{post.title} #[small= post.slug] + .uk-text-small + div(uk-grid).uk-grid-small + .uk-width-auto + span published: #{moment(post.created).format('MMM DD, YYYY [at] hh:mm:ss a')} + if post.updated + .uk-width-auto + span last update: #{moment(post.updated).format('MMM DD, YYYY [at] hh:mm:ss a')} + + .uk-width-auto + div(uk-grid).uk-grid-small + .uk-width-auto + a(href=`/admin/post/${post._id}`).uk-button.dtp-button-primary + +renderButtonIcon('fa-pen', 'Edit') + .uk-width-auto + button( + type="button", + data-post-id= post._id, + data-post-title= post.title, + onclick="return dtp.adminApp.deletePost(event);", + ).uk-button.dtp-button-danger + +renderButtonIcon('fa-trash', 'Delete') + else + div There are no posts at this time. \ No newline at end of file diff --git a/app/views/index.pug b/app/views/index.pug index c2d64ca..6a490c8 100644 --- a/app/views/index.pug +++ b/app/views/index.pug @@ -35,13 +35,24 @@ block content .uk-section.uk-section-default.uk-section-small .dtp-border-bottom.uk-margin h3.uk-heading-bullet Blog Posts - div(uk-grid) - .uk-width-1-3 - img(src="/img/default-poster.jpg").responsive - .uk-width-2-3 - h4.uk-margin-remove This is a Blog Title - .uk-margin.uk-margin-small This is a short description for blog post. - div.uk-text-small Published: #{moment(new Date()).format("MMM DD YYYY HH:MM a")} + + if Array.isArray(posts) && (posts.length > 0) + each post in posts + a(href=`/post/${post.slug}`).uk-display-block.uk-link-reset + div(uk-grid).uk-grid-small + .uk-width-1-3 + img(src="/img/default-poster.jpg").responsive + .uk-width-2-3 + article.uk-article + h4.uk-article-title= post.title + .uk-margin.uk-margin-small.uk-text-truncate= post.summary + .uk-article-meta + div(uk-grid).uk-grid-small + .uk-width-auto published: #{moment(post.created).format("MMM DD YYYY HH:MM a")} + if post.updated + .uk-width-auto updated: #{moment(post.updated).format("MMM DD YYYY HH:MM a")} + else + div There are no posts at this time. Please check back later! //- pre= JSON.stringify(gabTvChannel, null, 2) diff --git a/app/views/post/view.pug b/app/views/post/view.pug new file mode 100644 index 0000000..522d0cc --- /dev/null +++ b/app/views/post/view.pug @@ -0,0 +1,18 @@ +extends ../layouts/main +block content + + section.uk-section.uk-section-default + .uk-container + article(dtp-post-id= post._id) + .uk-margin + div(uk-grid) + .uk-width-expand + h1.article-title= post.title + if user && user.flags.isAdmin + .uk-width-auto + a(href=`/admin/post/${post._id}`).uk-button.dtp-button-text EDIT + .uk-text-lead= post.summary + .uk-margin + .uk-article-meta= moment(post.created).format('MMM DD, YYYY [at] hh:mm a') + .uk-margin + != post.content \ No newline at end of file diff --git a/config/limiter.js b/config/limiter.js index 3b4c4db..6ea01d2 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -135,6 +135,19 @@ module.exports = { }, }, + post: { + getView: { + total: 5, + expire: ONE_MINUTE, + message: 'You are reading posts too quickly', + }, + getIndex: { + total: 60, + expire: ONE_MINUTE, + message: 'You are refreshing too quickly', + }, + }, + /* * UserController */