17 changed files with 582 additions and 39 deletions
@ -0,0 +1,69 @@ |
|||
// page.js
|
|||
// Copyright (C) 2021 Digital Telepresence, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const DTP_COMPONENT_NAME = 'page'; |
|||
|
|||
const express = require('express'); |
|||
|
|||
const { SiteController, SiteError } = require('../../lib/site-lib'); |
|||
|
|||
class PageController 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('/page', router); |
|||
|
|||
router.use(this.dtp.services.gabTV.channelMiddleware('mrjoeprich')); |
|||
router.use(async (req, res, next) => { |
|||
res.locals.currentView = 'home'; |
|||
return next(); |
|||
}); |
|||
|
|||
router.param('pageSlug', this.populatePageSlug.bind(this)); |
|||
|
|||
router.get('/:pageSlug', |
|||
limiterService.create(limiterService.config.page.getView), |
|||
this.getView.bind(this), |
|||
); |
|||
} |
|||
|
|||
async populatePageSlug (req, res, next, pageSlug) { |
|||
const { page: pageService } = this.dtp.services; |
|||
try { |
|||
res.locals.page = await pageService.getBySlug(pageSlug); |
|||
if (!res.locals.page) { |
|||
throw new SiteError(404, 'Page not found'); |
|||
} |
|||
return next(); |
|||
} catch (error) { |
|||
this.log.error('failed to populate pageSlug', { pageSlug, error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getView (req, res, next) { |
|||
const { resource: resourceService } = this.dtp.services; |
|||
try { |
|||
await resourceService.recordView(req, 'Page', res.locals.page._id); |
|||
res.render('page/view'); |
|||
} catch (error) { |
|||
this.log.error('failed to service page view', { pageId: res.locals.page._id, error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
} |
|||
|
|||
module.exports = async (dtp) => { |
|||
let controller = new PageController(dtp); |
|||
return controller; |
|||
}; |
@ -0,0 +1,28 @@ |
|||
// page.js
|
|||
// Copyright (C) 2021 Digital Telepresence, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const mongoose = require('mongoose'); |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const PAGE_STATUS_LIST = ['draft','published','archived']; |
|||
|
|||
const PageSchema = new Schema({ |
|||
title: { type: String, required: true }, |
|||
slug: { type: String, required: true, lowercase: true, unique: true }, |
|||
image: { |
|||
header: { type: Schema.ObjectId, ref: 'Image' }, |
|||
icon: { type: Schema.ObjectId, ref: 'Image' }, |
|||
}, |
|||
content: { type: String, required: true, select: false }, |
|||
status: { type: String, enum: PAGE_STATUS_LIST, default: 'draft', index: true }, |
|||
menu: { |
|||
label: { type: String, required: true }, |
|||
order: { type: Number, default: 0, required: true }, |
|||
parent: { type: Schema.ObjectId, index: 1, ref: 'Page' }, |
|||
}, |
|||
}); |
|||
|
|||
module.exports = mongoose.model('Page', PageSchema); |
@ -0,0 +1,161 @@ |
|||
// page.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 ObjectId = mongoose.Types.ObjectId; |
|||
|
|||
const Page = mongoose.model('Page'); |
|||
|
|||
class PageService extends SiteService { |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, module.exports); |
|||
} |
|||
|
|||
async menuMiddleware (req, res, next) { |
|||
try { |
|||
const pages = await Page.find({ parent: { $exists: false } }).lean(); |
|||
res.locals.mainMenu = pages |
|||
.map((page) => { |
|||
return { |
|||
url: `/page/${page.slug}`, |
|||
label: page.menu.label, |
|||
order: page.menu.order, |
|||
}; |
|||
}) |
|||
.sort((a, b) => { |
|||
return a.order < b.order; |
|||
}); |
|||
return next(); |
|||
} catch (error) { |
|||
this.log.error('failed to build page menu', { error }); |
|||
return next(); |
|||
} |
|||
} |
|||
|
|||
async create (author, pageDefinition) { |
|||
const page = new Page(); |
|||
page.title = striptags(pageDefinition.title.trim()); |
|||
page.slug = this.createPageSlug(page._id, page.title); |
|||
page.content = pageDefinition.content.trim(); |
|||
page.status = pageDefinition.status || 'draft'; |
|||
page.menu = { |
|||
label: pageDefinition.menuLabel || page.title.slice(0, 10), |
|||
order: parseInt(pageDefinition.menuOrder || '0', 10), |
|||
}; |
|||
if (pageDefinition.parentPageId && (pageDefinition.parentPageId !== 'none')) { |
|||
page.menu.parent = pageDefinition.parentPageId; |
|||
} |
|||
await page.save(); |
|||
|
|||
return page.toObject(); |
|||
} |
|||
|
|||
async update (page, pageDefinition) { |
|||
const NOW = new Date(); |
|||
const updateOp = { |
|||
$set: { |
|||
updated: NOW, |
|||
}, |
|||
}; |
|||
|
|||
if (pageDefinition.title) { |
|||
updateOp.$set.title = striptags(pageDefinition.title.trim()); |
|||
} |
|||
if (pageDefinition.slug) { |
|||
let pageSlug = striptags(slug(pageDefinition.slug.trim())).split('-'); |
|||
while (ObjectId.isValid(pageSlug[pageSlug.length - 1])) { |
|||
pageSlug.pop(); |
|||
} |
|||
pageSlug = pageSlug.splice(0, 4); |
|||
pageSlug.push(page._id.toString()); |
|||
updateOp.$set.slug = `${pageSlug.join('-')}`; |
|||
} |
|||
if (pageDefinition.summary) { |
|||
updateOp.$set.summary = striptags(pageDefinition.summary.trim()); |
|||
} |
|||
if (pageDefinition.content) { |
|||
updateOp.$set.content = pageDefinition.content.trim(); |
|||
} |
|||
if (pageDefinition.status) { |
|||
updateOp.$set.status = striptags(pageDefinition.status.trim()); |
|||
} |
|||
|
|||
updateOp.$set.menu = { |
|||
label: pageDefinition.menuLabel || updateOp.$set.title.slice(0, 10), |
|||
order: parseInt(pageDefinition.menuOrder || '0', 10), |
|||
}; |
|||
if (pageDefinition.parentPageId && (pageDefinition.parentPageId !== 'none')) { |
|||
updateOp.$set.menu.parent = pageDefinition.parentPageId; |
|||
} |
|||
|
|||
await Page.updateOne( |
|||
{ _id: page._id }, |
|||
updateOp, |
|||
{ upsert: true }, |
|||
); |
|||
} |
|||
|
|||
async getPages (pagination, status = ['published']) { |
|||
if (!Array.isArray(status)) { |
|||
status = [status]; |
|||
} |
|||
const pages = await Page |
|||
.find({ status: { $in: status } }) |
|||
.sort({ created: -1 }) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.lean(); |
|||
return pages; |
|||
} |
|||
|
|||
async getById (pageId) { |
|||
const page = await Page |
|||
.findById(pageId) |
|||
.select('+content') |
|||
.lean(); |
|||
return page; |
|||
} |
|||
|
|||
async getBySlug (pageSlug) { |
|||
const slugParts = pageSlug.split('-'); |
|||
const pageId = slugParts[slugParts.length - 1]; |
|||
return this.getById(pageId); |
|||
} |
|||
|
|||
async getAvailablePages (excludedPageIds) { |
|||
const search = { }; |
|||
if (excludedPageIds) { |
|||
search._id = { $nin: excludedPageIds }; |
|||
} |
|||
const pages = await Page.find(search).lean(); |
|||
return pages; |
|||
} |
|||
|
|||
async deletePage (page) { |
|||
this.log.info('deleting page', { pageId: page._id }); |
|||
await Page.deleteOne({ _id: page._id }); |
|||
} |
|||
|
|||
createPageSlug (pageId, pageTitle) { |
|||
if ((typeof pageTitle !== 'string') || (pageTitle.length < 1)) { |
|||
throw new Error('Invalid input for making a page slug'); |
|||
} |
|||
const pageSlug = slug(pageTitle.trim().toLowerCase()).split('-').slice(0, 4).join('-'); |
|||
return `${pageSlug}-${pageId}`; |
|||
} |
|||
} |
|||
|
|||
module.exports = { |
|||
slug: 'page', |
|||
name: 'page', |
|||
create: (dtp) => { return new PageService(dtp); }, |
|||
}; |
@ -0,0 +1,89 @@ |
|||
extends ../layouts/main |
|||
block content |
|||
|
|||
- var actionUrl = page ? `/admin/page/${page._id}` : `/admin/page`; |
|||
|
|||
form(method="POST", action= actionUrl).uk-form |
|||
div(uk-grid).uk-grid-small |
|||
div(class="uk-width-1-1 uk-width-2-3@m") |
|||
.uk-margin |
|||
label(for="content").uk-form-label Page body |
|||
textarea(id="content", name="content", rows="4").uk-textarea= page ? page.content : undefined |
|||
|
|||
div(class="uk-width-1-1 uk-width-1-3@m") |
|||
.uk-margin |
|||
label(for="title").uk-form-label Page title |
|||
input(id="title", name="title", type="text", placeholder= "Enter page title", value= page ? page.title : undefined).uk-input |
|||
.uk-margin |
|||
label(for="slug").uk-form-label URL slug |
|||
- |
|||
var pageSlug; |
|||
pageSlug = page ? (page.slug || 'enter-slug-here').split('-') : ['enter', 'slug', 'here', '']; |
|||
pageSlug.pop(); |
|||
pageSlug = pageSlug.join('-'); |
|||
input(id="slug", name="slug", type="text", placeholder= "Enter page URL slug", value= page ? pageSlug : undefined).uk-input |
|||
.uk-text-small The slug is used in the link to the page https://#{site.domain}/page/#{pageSlug} |
|||
div(uk-grid) |
|||
.uk-width-auto |
|||
button(type="submit").uk-button.dtp-button-primary= page ? 'Update page' : 'Create page' |
|||
.uk-margin |
|||
label(for="status").uk-form-label Status |
|||
select(id="status", name="status").uk-select |
|||
option(value="draft", selected= page ? page.status === 'draft' : true) Draft |
|||
option(value="published", selected= page ? page.status === 'published' : false) Published |
|||
option(value="archived", selected= page ? page.status === 'archived' : false) Archived |
|||
|
|||
fieldset |
|||
legend Menu |
|||
.uk-margin |
|||
label(for="menu-label").uk-form-label Menu item label |
|||
input(id="menu-label", name="menuLabel", type="text", maxlength="80", placeholder="Enter label", value= page ? page.menu.label : undefined).uk-input |
|||
.uk-margin |
|||
label(for="menu-order").uk-form-label Menu item order |
|||
input(id="menu-order", name="menuOrder", type="number", min="0", value= page ? page.menu.order : 0).uk-input |
|||
if Array.isArray(availablePages) && (availablePages.length > 0) |
|||
.uk-margin |
|||
label(for="menu-parent").uk-form-label Parent page |
|||
select(id="menu-parent", name="parentPageId").uk-select |
|||
option(value= "none") --- Select parent page --- |
|||
each menuPage in availablePages |
|||
option(value= menuPage._id)= menuPage.title |
|||
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 code', |
|||
'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' }, |
|||
], |
|||
convert_urls: false, |
|||
skin: "oxide-dark", |
|||
content_css: "dark", |
|||
}); |
|||
|
|||
window.dtp.app.editor = editors[0]; |
|||
}); |
@ -0,0 +1,43 @@ |
|||
extends ../layouts/main |
|||
block content |
|||
|
|||
.uk-margin |
|||
div(uk-grid) |
|||
.uk-width-expand |
|||
h1.uk-text-truncate Pages |
|||
.uk-width-auto |
|||
a(href="/admin/page/compose").uk-button.dtp-button-primary |
|||
+renderButtonIcon('fa-plus', 'New Page') |
|||
|
|||
.uk-margin |
|||
if (Array.isArray(pages) && (pages.length > 0)) |
|||
|
|||
ul.uk-list |
|||
|
|||
each page in pages |
|||
|
|||
li(data-page-id= page._id) |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-expand |
|||
a(href=`/page/${page.slug}`).uk-display-block.uk-text-large.uk-text-truncate #{page.title} |
|||
|
|||
.uk-width-auto |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-auto(class={ |
|||
'uk-text-info': (page.status === 'draft'), |
|||
'uk-text-success': (page.status === 'published'), |
|||
'uk-text-danger': (page.status === 'archived'), |
|||
})= page.status |
|||
.uk-width-auto |
|||
a(href=`/admin/page/${page._id}`).uk-button.dtp-button-primary |
|||
+renderButtonIcon('fa-pen', 'Edit') |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-page-id= page._id, |
|||
data-page-title= page.title, |
|||
onclick="return dtp.adminApp.deletePage(event);", |
|||
).uk-button.dtp-button-danger |
|||
+renderButtonIcon('fa-trash', 'Delete') |
|||
else |
|||
div There are no pages at this time. |
@ -1,12 +0,0 @@ |
|||
extends ../layouts/main |
|||
block content |
|||
|
|||
include ../components/page-header |
|||
|
|||
section.uk-section.uk-section-default.uk-section-xsmall |
|||
.uk-container |
|||
h1 Pages |
|||
ul.uk-list |
|||
each page of pages |
|||
li |
|||
h1= page.title |
@ -1,9 +1,22 @@ |
|||
extends ../layouts/main |
|||
block content |
|||
|
|||
include ../components/page-header |
|||
include ../components/page-sidebar |
|||
|
|||
section.uk-section.uk-section-default |
|||
.container |
|||
h1= page.title |
|||
article= page.content |
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
div(uk-grid) |
|||
.uk-width-2-3 |
|||
article(dtp-page-id= page._id) |
|||
.uk-margin |
|||
div(uk-grid) |
|||
.uk-width-expand |
|||
h1.article-title= page.title |
|||
if user && user.flags.isAdmin |
|||
.uk-width-auto |
|||
a(href=`/admin/page/${page._id}`).uk-button.dtp-button-text EDIT |
|||
.uk-margin |
|||
!= page.content |
|||
|
|||
.uk-width-1-3 |
|||
+renderPageSidebar() |
Loading…
Reference in new issue