Browse Source

Posts and a couple newsletter updates/fixes

pull/1/head
Rob Colbert 3 years ago
parent
commit
00d8c1e10e
  1. 6
      app/controllers/admin/newsletter.js
  2. 83
      app/controllers/admin/post.js
  3. 6
      app/controllers/home.js
  4. 103
      app/controllers/post.js
  5. 23
      app/models/article.js
  6. 28
      app/models/post.js
  7. 66
      app/services/article.js
  8. 125
      app/services/post.js
  9. 67
      app/views/admin/post/editor.pug
  10. 45
      app/views/admin/post/index.pug
  11. 19
      app/views/index.pug
  12. 18
      app/views/post/view.pug
  13. 13
      config/limiter.js

6
app/controllers/admin/newsletter.js

@ -66,10 +66,10 @@ class NewsletterController extends SiteController {
async postCreateNewsletter (req, res, next) { async postCreateNewsletter (req, res, next) {
const { newsletter: newsletterService } = this.dtp.services; const { newsletter: newsletterService } = this.dtp.services;
try { try {
const newsletter = await newsletterService.create(req.user, req.body); await newsletterService.create(req.user, req.body);
res.redirect('/admin/newsletter'); res.redirect('/admin/newsletter');
} catch (error) { } catch (error) {
this.log.error('failed to update newsletter', { error }); this.log.error('failed to create newsletter', { error });
return next(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; const { newsletter: newsletterService, displayEngine: displayEngineService } = this.dtp.services;
try { try {
const displayList = displayEngineService.createDisplayList('delete-newsletter'); const displayList = displayEngineService.createDisplayList('delete-newsletter');

83
app/controllers/admin/post.js

@ -7,7 +7,7 @@
const DTP_COMPONENT_NAME = 'admin:post'; const DTP_COMPONENT_NAME = 'admin:post';
const express = require('express'); const express = require('express');
const { SiteController } = require('../../../lib/site-lib'); const { SiteController, SiteError } = require('../../../lib/site-lib');
class PostController extends SiteController { class PostController extends SiteController {
@ -25,17 +25,94 @@ class PostController extends SiteController {
router.param('postId', this.populatePostId.bind(this)); 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)); router.get('/', this.getIndex.bind(this));
return router; return router;
} }
async populatePostId (req, res, next/*, postId*/) { 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(); 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) { 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'); 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,
});
}
} }
} }

6
app/controllers/home.js

@ -35,10 +35,12 @@ class HomeController extends SiteController {
} }
async getHome (req, res, next) { async getHome (req, res, next) {
const { gabTV: gabTvService } = this.dtp.services; const { gabTV: gabTvService, post: postService } = this.dtp.services;
try { try {
res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.gabTvChannel = await gabTvService.getChannelEpisodes('mrjoeprich'); 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'); res.render('index');
} catch (error) { } catch (error) {
return next(error); return next(error);

103
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;
};

23
app/models/article.js

@ -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);

28
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);

66
app/services/article.js

@ -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); },
};

125
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); },
};

67
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];
});

45
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.

19
app/views/index.pug

@ -35,13 +35,24 @@ block content
.uk-section.uk-section-default.uk-section-small .uk-section.uk-section-default.uk-section-small
.dtp-border-bottom.uk-margin .dtp-border-bottom.uk-margin
h3.uk-heading-bullet Blog Posts h3.uk-heading-bullet Blog Posts
div(uk-grid)
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 .uk-width-1-3
img(src="/img/default-poster.jpg").responsive img(src="/img/default-poster.jpg").responsive
.uk-width-2-3 .uk-width-2-3
h4.uk-margin-remove This is a Blog Title article.uk-article
.uk-margin.uk-margin-small This is a short description for blog post. h4.uk-article-title= post.title
div.uk-text-small Published: #{moment(new Date()).format("MMM DD YYYY HH:MM a")} .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) //- pre= JSON.stringify(gabTvChannel, null, 2)

18
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

13
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 * UserController
*/ */

Loading…
Cancel
Save