diff --git a/app/controllers/post.js b/app/controllers/post.js index 0fa81cc..0261186 100644 --- a/app/controllers/post.js +++ b/app/controllers/post.js @@ -55,6 +55,8 @@ class PostController extends SiteController { router.post('/:postId/image', requireAuthorPrivileges, upload.single('imageFile'), this.postUpdateImage.bind(this)); router.post('/:postId', requireAuthorPrivileges, this.postUpdatePost.bind(this)); + router.post('/:postId/tags', requireAuthorPrivileges, this.postUpdatePostTags.bind(this)); + router.post('/', requireAuthorPrivileges, this.postCreatePost.bind(this)); router.get('/:postId/edit', requireAuthorPrivileges, this.getEditor.bind(this)); @@ -202,10 +204,37 @@ class PostController extends SiteController { await postService.update(req.user, res.locals.post, req.body); res.redirect(`/post/${res.locals.post.slug}`); } catch (error) { - this.log.error('failed to update post', { newletterId: res.locals.post._id, error }); + this.log.error('failed to update post', { postId: res.locals.post._id, error }); return next(error); } } + + async postUpdatePostTags (req, res) { + const { post: postService } = this.dtp.services; + try { + if(!req.user.flags.isAdmin) + { + if (!req.user._id.equals(res.locals.post.author._id)) { + throw new SiteError(403, 'Only authors or admins can update tags.'); + } + } + await postService.updateTags(req.user, res.locals.post, req.body); + const displayList = this.createDisplayList(); + displayList.showNotification( + 'Profile photo updated successfully.', + 'success', + 'bottom-center', + 2000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to update post tags', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } async postCreatePost (req, res, next) { const { post: postService } = this.dtp.services; diff --git a/app/controllers/tag.js b/app/controllers/tag.js new file mode 100644 index 0000000..485555b --- /dev/null +++ b/app/controllers/tag.js @@ -0,0 +1,97 @@ +// post.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const express = require('express'); +// const multer = require('multer'); + +const { SiteController, SiteError } = require('../../lib/site-lib'); + +class TagController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + const { dtp } = this; + // const { + // post: postService, + // limiter: limiterService, + // session: sessionService, + // } = dtp.services; + + // const authRequired = sessionService.authCheckMiddleware({ requiredLogin: true }); + + const router = express.Router(); + dtp.app.use('/tag', router); + + router.use(async (req, res, next) => { + res.locals.currentView = 'home'; + return next(); + }); + + router.param('tagSlug', this.populateTagSlug.bind(this)); + + router.get('/:tagSlug', this.getSearchView.bind(this)); + } + + async populateTagSlug (req, res, next, tagSlug) { + const { post: postService } = this.dtp.services; + try { + var allPosts = false; + var statusArray = ['published']; + if (req.user) { + if (req.user.flags.isAdmin) { + statusArray.push('draft'); + allPosts = true; + } + } + res.locals.allPosts = allPosts; + res.locals.tagSlug = tagSlug; + tagSlug = tagSlug.replace("_", " "); + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.posts = await postService.getByTags(tagSlug, res.locals.pagination, statusArray); + res.locals.tag = tagSlug; + return next(); + + } catch (error) { + this.log.error('failed to populate tagSlug', { tagSlug, error }); + return next(error); + } + } + + async getSearchView (req, res) { + try { + res.locals.pageTitle = `Tag ${res.locals.tag} on ${this.dtp.config.site.name}`; + + res.render('tag/view'); + } catch (error) { + this.log.error('failed to service post view', { postId: res.locals.post._id, error }); + throw SiteError("Error getting tag view:", error ); + } + } + + + 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('tag/index'); + } catch (error) { + return next(error); + } + } +} + +module.exports = { + slug: 'tag', + name: 'tag', + create: async (dtp) => { + let controller = new TagController(dtp); + return controller; + }, +}; \ No newline at end of file diff --git a/app/models/post.js b/app/models/post.js index 736338f..3ba931a 100644 --- a/app/models/post.js +++ b/app/models/post.js @@ -23,6 +23,7 @@ const PostSchema = new Schema({ author: { type: Schema.ObjectId, required: true, index: 1, refPath: 'authorType' }, image: { type: Schema.ObjectId, ref: 'Image' }, title: { type: String, required: true }, + tags: { type: [String], lowercase: true }, slug: { type: String, required: true, lowercase: true, unique: true }, summary: { type: String }, content: { type: String, select: false }, diff --git a/app/services/post.js b/app/services/post.js index eee7062..a4378fd 100644 --- a/app/services/post.js +++ b/app/services/post.js @@ -74,6 +74,12 @@ class PostService extends SiteService { } } + if (postDefinition.tags) { + postDefinition.tags = postDefinition.tags.split(',').map((tag) => striptags(tag.trim())); + } else { + postDefinition.tags = [ ]; + } + const post = new Post(); post.created = NOW; post.authorType = author.type; @@ -82,6 +88,7 @@ class PostService extends SiteService { post.slug = this.createPostSlug(post._id, post.title); post.summary = striptags(postDefinition.summary.trim()); post.content = postDefinition.content.trim(); + post.tags = postDefinition.tags; post.status = postDefinition.status || 'draft'; post.flags = { enableComments: postDefinition.enableComments === 'on', @@ -130,11 +137,17 @@ class PostService extends SiteService { if (postDefinition.content) { updateOp.$set.content = postDefinition.content.trim(); } + + await this.updateTags(post._id, postDefinition.tags); + if (!postDefinition.status) { throw new SiteError(406, 'Must include post status'); } - if (post.status !== 'published' && postDefinition.status === 'published') { + // const postWillBeUnpublished = post.status === 'published' && postDefinition.status !== 'published'; + const postWillBePublished = post.status !== 'published' && postDefinition.status === 'published'; + + if (postWillBePublished) { if (!user.permissions.canPublishPosts) { throw new SiteError(403, 'You are not permitted to publish posts'); } @@ -171,6 +184,42 @@ class PostService extends SiteService { } } + // pass the post._id and its tags to function + async updateTags (id, tags) { + if (tags) { + tags = tags.split(',').map((tag) => striptags(tag.trim().toLowerCase())); + } else { + tags = [ ]; + } + + const NOW = new Date(); + const updateOp = { + $setOnInsert: { + created: NOW, + }, + $set: { + updated: NOW, + }, + }; + updateOp.$set.tags = tags; + await Post.findOneAndUpdate( + { _id: id }, + updateOp, + ); + } + + async getByTags (tag, pagination, status = ['published']) { + if (!Array.isArray(status)) { + status = [status]; + } + const posts = await Post.find( { status: { $in: status }, tags: tag } ) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populatePost); + return posts; + } + async updateImage (user, post, file) { const { image: imageService } = this.dtp.services; diff --git a/app/views/post/editor.pug b/app/views/post/editor.pug index c37f5a5..56b97e3 100644 --- a/app/views/post/editor.pug +++ b/app/views/post/editor.pug @@ -33,12 +33,15 @@ block content } input(id="slug", name="slug", type="text", placeholder= "Enter post URL slug", value= post ? postSlug : undefined).uk-input .uk-text-small The slug is used in the link to the page https://#{site.domain}/post/#{post ? post.slug : 'your-slug-here'} + .uk-margin + label(for="tags").uk-form-label Post tags + input(id="tags", name="tags", placeholder= "Enter a comma-separated list of tags", value= (post.tags || [ ]).join(', ')).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.uk-button-primary= post ? 'Update post' : 'Create post' + button(type="submit").uk-button.uk-button-primary= 'Update post' .uk-margin label(for="status").uk-form-label Status select(id="status", name="status").uk-select diff --git a/app/views/post/view.pug b/app/views/post/view.pug index ae9f140..aab9db8 100644 --- a/app/views/post/view.pug +++ b/app/views/post/view.pug @@ -24,7 +24,15 @@ block content div(uk-grid) .uk-width-auto +renderGabShareButton(`https://${site.domainKey}/post/${post.slug}`, `${post.title} - ${post.summary}`) - + +renderSectionTitle('Post tags') + if Array.isArray(post.tags) && (post.tags.length > 0) + div(uk-grid).uk-grid-small + each tag in post.tags + - + var tagSlug; + tagSlug = tag.replace(" ", "_") + a(href=`/tag/${tagSlug}`).uk-display-block.uk-link-reset.uk-margin-small= tag + .uk-margin .uk-article-meta div(uk-grid).uk-grid-small.uk-flex-top diff --git a/app/views/tag/components/list.pug b/app/views/tag/components/list.pug new file mode 100644 index 0000000..534d976 --- /dev/null +++ b/app/views/tag/components/list.pug @@ -0,0 +1,19 @@ +mixin renderPostSummaryFull (post) + div(uk-grid).uk-grid-small + if post.image + .uk-width-auto + img(src= `/image/${post.image._id}`).uk-width-medium + else + .uk-width-auto + img(src="/img/default-poster.jpg").uk-width-medium + .uk-width-expand + .uk-text-large.uk-text-bold(style="line-height: 1em;") + a(href=`/post/${post.slug}`)= `${post.title}` + .uk-text-small.uk-text-muted + div + div= moment(post.created).fromNow() + span by + a(href=`/user/${post.author.username}`)=` ${post.author.username}` + if user && allPosts + div= `Status: ${post.status}` + div= post.summary \ No newline at end of file diff --git a/app/views/tag/view.pug b/app/views/tag/view.pug new file mode 100644 index 0000000..9ace8d5 --- /dev/null +++ b/app/views/tag/view.pug @@ -0,0 +1,37 @@ +extends ../layouts/main-sidebar + +include components/list + +include ../components/pagination-bar + +block content + + + if Array.isArray(posts) && (posts.length > 0) + h3= `Posts with the tag ${tag}.` + ul.uk-list.uk-list-divider + each post in posts + li + +renderPostSummaryFull(post) + + .uk-card-footer + +renderPaginationBar(`/tag/${tagSlug}`, posts.length ) + //- li + if post.image + img(src= `/image/${post.image._id}`, href=`/post/${post.slug}`, style="max-height: 350px; object-fit: cover; vertical-align:middle;margin:0px 20px;").responsive + else + img(src="/img/default-poster.jpg", href=`/post/${post.slug}`, style="max-height: 350px; object-fit: cover; vertical-align:middle;margin:0px 20px;").responsive + a(href=`/post/${post.slug}`).uk-display-block + div.h2= post.title + + .uk-article-meta + div(uk-grid).uk-grid-small.uk-text-small + .uk-width-expand + a(href=`/post/${post.slug}`)= moment(post.created).fromNow() + span by + a(href=`/user/${post.author.username}`)=` ${post.author.username}` + .uk-width-expand + div= post.summary + + else + h3= `There are no posts with the tag ${tag}.` \ No newline at end of file