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