13 changed files with 506 additions and 106 deletions
@ -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; |
||||
|
}; |
@ -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); |
|
@ -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); |
@ -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); }, |
|
||||
}; |
|
@ -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); }, |
||||
|
}; |
@ -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]; |
||||
|
}); |
@ -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. |
@ -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 |
Loading…
Reference in new issue