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 |
extends ../layouts/main |
||||
block content |
block content |
||||
|
|
||||
include ../components/page-header |
include ../components/page-sidebar |
||||
|
|
||||
section.uk-section.uk-section-default |
section.uk-section.uk-section-default.uk-section-small |
||||
.container |
.uk-container |
||||
h1= page.title |
div(uk-grid) |
||||
article= page.content |
.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