Finalised official release 2.0.0

This commit is contained in:
Jonatan Nilsson 2019-10-02 00:16:11 +00:00
parent ecbf015cb6
commit f760ec6d8b
14 changed files with 203 additions and 37 deletions

View file

@ -47,15 +47,55 @@ const Article = bookshelf.createModel({
}) })
}, },
}, { }, {
getSingle(id, withRelated = [], require = true, ctx = null) { getAll(ctx, where = {}, withRelated = [], orderBy = 'id', limitToday = false) {
return this.query(qb => { return this.query(qb => {
qb.where({ id: Number(id) || 0 }) this.baseQueryAll(ctx, qb, where, orderBy)
.orWhere({ path: id }) if (limitToday) {
qb.where('published_at', '<=', (new Date()).toISOString())
}
})
.fetchPage({
pageSize: ctx.state.pagination.perPage,
page: ctx.state.pagination.page,
withRelated,
ctx: ctx,
})
.then(result => {
ctx.state.pagination.total = result.pagination.rowCount
return result
})
},
getSingle(id, withRelated = [], require = true, ctx = null, limitToday = false) {
return this.query(qb => {
qb.where(subq => {
subq.where({ id: Number(id) || 0 })
.orWhere({ path: id })
})
if (limitToday && (!ctx || !ctx.state.user || ctx.state.user.level < 10)) {
qb.where('published_at', '<=', (new Date()).toISOString())
}
}) })
.fetch({ require, withRelated, ctx }) .fetch({ require, withRelated, ctx })
}, },
getAllFromPage(ctx, pageId, withRelated = [], orderBy = 'id') { async getFeatured(withRelated = [], ctx = null) {
let data = await this.query(qb => {
qb.where({ is_featured: true })
.where('published_at', '<=', (new Date()).toISOString())
})
.fetch({ require: false, withRelated, ctx })
if (!data) {
data = await this.query(qb => {
qb.where('published_at', '<=', (new Date()).toISOString())
.whereNotNull('banner_id')
})
.fetch({ require: false, withRelated, ctx })
}
return data
},
getAllFromPage(ctx, pageId, withRelated = [], orderBy = 'id', limitToday = false) {
return this.query(qb => { return this.query(qb => {
this.baseQueryAll(ctx, qb, {}, orderBy) this.baseQueryAll(ctx, qb, {}, orderBy)
qb.leftOuterJoin('pages', 'articles.parent_id', 'pages.id') qb.leftOuterJoin('pages', 'articles.parent_id', 'pages.id')
@ -63,6 +103,9 @@ const Article = bookshelf.createModel({
subq.where('pages.id', pageId) subq.where('pages.id', pageId)
.orWhere('pages.parent_id', pageId) .orWhere('pages.parent_id', pageId)
}) })
if (limitToday) {
qb.where('published_at', '<=', (new Date()).toISOString())
}
qb.select('articles.*') qb.select('articles.*')
}) })
.fetchPage({ .fetchPage({
@ -77,9 +120,18 @@ const Article = bookshelf.createModel({
}) })
}, },
setAllUnfeatured() {
return bookshelf.knex('articles')
.where({ is_featured: true })
.update({
is_featured: false,
})
},
getFrontpageArticles(page = 1) { getFrontpageArticles(page = 1) {
return this.query(qb => { return this.query(qb => {
qb.orderBy('updated_at', 'DESC') qb.orderBy('published_at', 'DESC')
.where('published_at', '<=', (new Date()).toISOString())
}) })
.fetchPage({ .fetchPage({
pageSize: 10, pageSize: 10,

View file

@ -13,14 +13,14 @@ export default class ArticleRoutes {
async getAllArticles(ctx) { async getAllArticles(ctx) {
await this.security.ensureIncludes(ctx) await this.security.ensureIncludes(ctx)
ctx.body = await this.Article.getAll(ctx, { }, ctx.state.filter.includes, ctx.query.sort || '-id') ctx.body = await this.Article.getAll(ctx, { }, ctx.state.filter.includes, ctx.query.sort || '-published_at')
} }
/** GET: /api/pages/:pageId/articles */ /** GET: /api/pages/:pageId/articles */
async getAllPageArticles(ctx) { async getAllPageArticles(ctx) {
await this.security.ensureIncludes(ctx) await this.security.ensureIncludes(ctx)
ctx.body = await this.Article.getAllFromPage(ctx, ctx.params.pageId, ctx.state.filter.includes, ctx.query.sort || '-id') ctx.body = await this.Article.getAllFromPage(ctx, ctx.params.pageId, ctx.state.filter.includes, ctx.query.sort || '-published_at')
} }
/** GET: /api/articles/:id */ /** GET: /api/articles/:id */
@ -30,6 +30,27 @@ export default class ArticleRoutes {
ctx.body = await this.Article.getSingle(ctx.params.id, ctx.state.filter.includes, true, ctx) ctx.body = await this.Article.getSingle(ctx.params.id, ctx.state.filter.includes, true, ctx)
} }
/** GET: /api/articles/public */
async getPublicAllArticles(ctx) {
await this.security.ensureIncludes(ctx)
ctx.body = await this.Article.getAll(ctx, { }, ctx.state.filter.includes, ctx.query.sort || '-published_at', true)
}
/** GET: /api/pages/:pageId/articles/public */
async getPublicAllPageArticles(ctx) {
await this.security.ensureIncludes(ctx)
ctx.body = await this.Article.getAllFromPage(ctx, ctx.params.pageId, ctx.state.filter.includes, ctx.query.sort || '-published_at', true)
}
/** GET: /api/articles/public/:id */
async getPublicSingleArticle(ctx) {
await this.security.ensureIncludes(ctx)
ctx.body = await this.Article.getSingle(ctx.params.id, ctx.state.filter.includes, true, ctx, true)
}
/** POST: /api/articles */ /** POST: /api/articles */
async createArticle(ctx) { async createArticle(ctx) {
await this.security.validUpdate(ctx) await this.security.validUpdate(ctx)
@ -41,6 +62,10 @@ export default class ArticleRoutes {
async updateArticle(ctx) { async updateArticle(ctx) {
await this.security.validUpdate(ctx) await this.security.validUpdate(ctx)
if (ctx.request.body.is_featured) {
await Article.setAllUnfeatured()
}
let page = await this.Article.getSingle(ctx.params.id) let page = await this.Article.getSingle(ctx.params.id)
page.set(ctx.request.body) page.set(ctx.request.body)

View file

@ -12,6 +12,8 @@ const validFields = [
'parent_id', 'parent_id',
'media_id', 'media_id',
'banner_id', 'banner_id',
'published_at',
'is_featured',
] ]
export async function ensureIncludes(ctx) { export async function ensureIncludes(ctx) {
@ -34,4 +36,8 @@ export async function validUpdate(ctx) {
if (out.length > 0) { if (out.length > 0) {
ctx.throw(422, `Body had following invalid properties: ${out.join(', ')}`) ctx.throw(422, `Body had following invalid properties: ${out.join(', ')}`)
} }
if (ctx.request.body.published_at) {
ctx.request.body.published_at = new Date(ctx.request.body.published_at)
}
} }

View file

@ -37,9 +37,12 @@ router.put('/api/pages/:id', restrict(access.Manager), page.updatePage.bind(page
router.del('/api/pages/:id', restrict(access.Manager), page.removePage.bind(page)) router.del('/api/pages/:id', restrict(access.Manager), page.removePage.bind(page))
const article = new ArticleRoutes() const article = new ArticleRoutes()
router.get('/api/articles', article.getAllArticles.bind(article)) router.get('/api/articles', restrict(access.Manager), article.getAllArticles.bind(article))
router.get('/api/pages/:pageId/articles', article.getAllPageArticles.bind(article)) router.get('/api/articles/public', article.getPublicAllArticles.bind(article))
router.get('/api/articles/:id', article.getSingleArticle.bind(article)) router.get('/api/articles/public/:id', article.getPublicSingleArticle.bind(article))
router.get('/api/pages/:pageId/articles/public', article.getPublicAllPageArticles.bind(article))
router.get('/api/pages/:pageId/articles', restrict(access.Manager), article.getAllPageArticles.bind(article))
router.get('/api/articles/:id', restrict(access.Manager), article.getSingleArticle.bind(article))
router.post('/api/articles', restrict(access.Manager), article.createArticle.bind(article)) router.post('/api/articles', restrict(access.Manager), article.createArticle.bind(article))
router.put('/api/articles/:id', restrict(access.Manager), article.updateArticle.bind(article)) router.put('/api/articles/:id', restrict(access.Manager), article.updateArticle.bind(article))
router.del('/api/articles/:id', restrict(access.Manager), article.removeArticle.bind(article)) router.del('/api/articles/:id', restrict(access.Manager), article.removeArticle.bind(article))

View file

@ -3,6 +3,7 @@ import defaults from './defaults.mjs'
import access from './access/index.mjs' import access from './access/index.mjs'
import { restrict } from './access/middleware.mjs' import { restrict } from './access/middleware.mjs'
import { serveIndex } from './serveindex.mjs' import { serveIndex } from './serveindex.mjs'
import config from './config.mjs'
const restrictAdmin = restrict(access.Manager) const restrictAdmin = restrict(access.Manager)
@ -36,8 +37,13 @@ export function serve(docRoot, pathname, options = {}) {
if (filepath.indexOf('admin') >= 0 if (filepath.indexOf('admin') >= 0
&& (filepath.indexOf('js') >= 0 && (filepath.indexOf('js') >= 0
|| filepath.indexOf('css') >= 0)) { || filepath.indexOf('css') >= 0)) {
await restrictAdmin(ctx) if (filepath.indexOf('.map') === -1) {
ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate') await restrictAdmin(ctx)
ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate')
} else if (config.get('NODE_ENV') !== 'development') {
ctx.status = 404
return
}
} }
return send(ctx, filepath, opts).catch((er) => { return send(ctx, filepath, opts).catch((er) => {

View file

@ -77,6 +77,7 @@ export async function serveIndex(ctx, path) {
let tree = null let tree = null
let data = null let data = null
let links = null let links = null
let featured = null
let url = frontend + ctx.request.url let url = frontend + ctx.request.url
let image = frontend + '/assets/img/heart.jpg' let image = frontend + '/assets/img/heart.jpg'
let title = 'NFP Moe - Anime/Manga translation group' let title = 'NFP Moe - Anime/Manga translation group'
@ -88,6 +89,11 @@ export async function serveIndex(ctx, path) {
{ id: x.id, name: x.name, path: x.path } { id: x.id, name: x.name, path: x.path }
)) ))
)) ))
featured = await Article.getFeatured(['files', 'media', 'banner'])
if (featured) {
featured = mapArticle(featured.toJSON())
}
if (path === '/') { if (path === '/') {
data = await Article.getFrontpageArticles(Number(ctx.query.page || '1')) data = await Article.getFrontpageArticles(Number(ctx.query.page || '1'))
@ -108,26 +114,30 @@ export async function serveIndex(ctx, path) {
if (id) { if (id) {
let found let found
if (path.startsWith('/article/')) { if (path.startsWith('/article/')) {
found = await Article.getSingle(id, ['media', 'parent', 'banner', 'files']) found = await Article.getSingle(id, ['media', 'parent', 'banner', 'files'], false, null, true)
found = mapArticle(found.toJSON()) if (found) {
found = mapArticle(found.toJSON())
}
data = found data = found
} else { } else {
found = await Page.getSingle(id, ['media', 'banner', 'children']) found = await Page.getSingle(id, ['media', 'banner', 'children'])
found = mapPage(found.toJSON()) found = mapPage(found.toJSON())
data = found data = found
} }
if (found.media) { if (found) {
image = found.media.large_url if (found.media) {
} else if (found.banner) { image = found.media.large_url
image = found.banner.large_url } else if (found.banner) {
} image = found.banner.large_url
if (found.description) { }
description = striptags(found.description) if (found.description) {
} description = striptags(found.description)
if (found.parent) { }
title = found.name + ' - ' + found.parent.name + ' - NFP Moe' if (found.parent) {
} else { title = found.name + ' - ' + found.parent.name + ' - NFP Moe'
title = found.name + ' - NFP Moe' } else {
title = found.name + ' - NFP Moe'
}
} }
} }
} }
@ -141,6 +151,7 @@ export async function serveIndex(ctx, path) {
tree: JSON.stringify(tree), tree: JSON.stringify(tree),
data: JSON.stringify(data), data: JSON.stringify(data),
links: JSON.stringify(links), links: JSON.stringify(links),
featured: JSON.stringify(featured),
url: url, url: url,
image: image, image: image,
title: title, title: title,

View file

@ -70,12 +70,22 @@ const AdminArticles = {
name: '-- Frontpage --', name: '-- Frontpage --',
} }
} }
let other = ''
let className = ''
if (new Date() < new Date(article.published_at)) {
other = '(hidden)'
className = 'rowhidden'
} else if (article.is_featured) {
other = '(featured)'
className = 'rowfeatured'
}
return [ return [
m('tr', [ m('tr', { class: className }, [
m('td', m(m.route.Link, { href: '/admin/articles/' + article.id }, article.name)), m('td', m(m.route.Link, { href: '/admin/articles/' + article.id }, article.name)),
m('td', m(m.route.Link, { href: parent.path }, parent.name)), m('td', m(m.route.Link, { href: parent.path }, parent.name)),
m('td', m(m.route.Link, { href: '/article/' + article.path }, '/article/' + article.path)), m('td', m(m.route.Link, { href: '/article/' + article.path }, '/article/' + article.path)),
m('td.right', article.updated_at.replace('T', ' ').split('.')[0]), m('td.right', article.published_at.replace('T', ' ').split('.')[0]),
m('td.right', other),
m('td.right', m('button', { onclick: function() { vnode.state.removeArticle = article } }, 'Remove')), m('td.right', m('button', { onclick: function() { vnode.state.removeArticle = article } }, 'Remove')),
]), ]),
] ]
@ -102,7 +112,8 @@ const AdminArticles = {
m('th', 'Title'), m('th', 'Title'),
m('th', 'Page'), m('th', 'Page'),
m('th', 'Path'), m('th', 'Path'),
m('th.right', 'Updated'), m('th.right', 'Publish'),
m('th.right', 'Other'),
m('th.right', 'Actions'), m('th.right', 'Actions'),
]) ])
), ),

View file

@ -46,6 +46,16 @@ article.editarticle {
} }
} }
table {
tr.rowhidden td {
background: #e6e6e6;
}
tr.rowfeatured td {
background: hsl(120, 60%, 85%);
}
}
h5 { h5 {
margin-bottom: 20px; margin-bottom: 20px;
} }

View file

@ -64,6 +64,8 @@ const EditArticle = {
media: null, media: null,
banner: null, banner: null,
files: [], files: [],
is_featured: false,
published_at: new Date().toISOString(),
} }
this.editedPath = false this.editedPath = false
this.loadedFroala = Froala.loadedFroala this.loadedFroala = Froala.loadedFroala
@ -73,6 +75,7 @@ const EditArticle = {
.then(function(result) { .then(function(result) {
vnode.state.editedPath = true vnode.state.editedPath = true
vnode.state.article = result vnode.state.article = result
EditArticle.parsePublishedAt(vnode, null)
document.title = 'Editing: ' + result.name + ' - Admin NFP Moe' document.title = 'Editing: ' + result.name + ' - Admin NFP Moe'
}) })
.catch(function(err) { .catch(function(err) {
@ -86,6 +89,7 @@ const EditArticle = {
m.redraw() m.redraw()
}) })
} else { } else {
EditArticle.parsePublishedAt(vnode, new Date())
document.title = 'Create Article - Admin NFP Moe' document.title = 'Create Article - Admin NFP Moe'
if (vnode.state.froala) { if (vnode.state.froala) {
vnode.state.froala.html.set(this.article.description) vnode.state.froala.html.set(this.article.description)
@ -93,8 +97,16 @@ const EditArticle = {
} }
}, },
parsePublishedAt: function(vnode, date) {
vnode.state.article.published_at = ((date && date.toISOString() || vnode.state.article.published_at).split('.')[0]).substr(0, 16)
},
updateValue: function(name, e) { updateValue: function(name, e) {
this.article[name] = e.currentTarget.value if (name === 'is_featured') {
this.article[name] = e.currentTarget.checked
} else {
this.article[name] = e.currentTarget.value
}
if (name === 'path') { if (name === 'path') {
this.editedPath = true this.editedPath = true
} else if (name === 'name' && !this.editedPath) { } else if (name === 'name' && !this.editedPath) {
@ -145,6 +157,8 @@ const EditArticle = {
description: this.article.description, description: this.article.description,
banner_id: this.article.banner && this.article.banner.id, banner_id: this.article.banner && this.article.banner.id,
media_id: this.article.media && this.article.media.id, media_id: this.article.media && this.article.media.id,
published_at: new Date(this.article.published_at),
is_featured: this.article.is_featured,
}) })
} else { } else {
promise = Article.createArticle({ promise = Article.createArticle({
@ -154,6 +168,8 @@ const EditArticle = {
description: this.article.description, description: this.article.description,
banner_id: this.article.banner && this.article.banner.id, banner_id: this.article.banner && this.article.banner.id,
media_id: this.article.media && this.article.media.id, media_id: this.article.media && this.article.media.id,
published_at: new Date(this.article.published_at),
is_featured: this.article.is_featured,
}) })
} }
@ -163,6 +179,7 @@ const EditArticle = {
res.banner = vnode.state.article.banner res.banner = vnode.state.article.banner
res.files = vnode.state.article.files res.files = vnode.state.article.files
vnode.state.article = res vnode.state.article = res
EditArticle.parsePublishedAt(vnode, null)
} else { } else {
m.route.set('/admin/articles/' + res.id) m.route.set('/admin/articles/' + res.id)
} }
@ -248,6 +265,12 @@ const EditArticle = {
m('select', { m('select', {
onchange: this.updateParent.bind(this), onchange: this.updateParent.bind(this),
}, parents.map(function(item) { return m('option', { value: item.id || -1, selected: item.id === vnode.state.article.parent_id }, item.name) })), }, parents.map(function(item) { return m('option', { value: item.id || -1, selected: item.id === vnode.state.article.parent_id }, item.name) })),
m('label', 'Path'),
m('input', {
type: 'text',
value: this.article.path,
oninput: this.updateValue.bind(this, 'path'),
}),
m('label', 'Name'), m('label', 'Name'),
m('input', { m('input', {
type: 'text', type: 'text',
@ -266,11 +289,17 @@ const EditArticle = {
}) })
: null : null
), ),
m('label', 'Path'), m('label', 'Publish at'),
m('input', { m('input', {
type: 'text', type: 'datetime-local',
value: this.article.path, value: this.article.published_at,
oninput: this.updateValue.bind(this, 'path'), oninput: this.updateValue.bind(this, 'published_at'),
}),
m('label', 'Make featured'),
m('input', {
type: 'checkbox',
checked: this.article.is_featured,
oninput: this.updateValue.bind(this, 'is_featured'),
}), }),
m('div.loading-spinner', { hidden: this.loadedFroala }), m('div.loading-spinner', { hidden: this.loadedFroala }),
m('input', { m('input', {

View file

@ -16,7 +16,7 @@ exports.getAllArticlesPagination = function(options) {
extra += '&includes=' + options.includes.join(',') extra += '&includes=' + options.includes.join(',')
} }
return '/api/articles?' + extra return '/api/articles/public?' + extra
} }
exports.getAllPageArticlesPagination = function(pageId, options) { exports.getAllPageArticlesPagination = function(pageId, options) {
@ -35,12 +35,12 @@ exports.getAllPageArticlesPagination = function(pageId, options) {
extra += '&includes=' + options.includes.join(',') extra += '&includes=' + options.includes.join(',')
} }
return '/api/pages/' + pageId + '/articles?' + extra return '/api/pages/' + pageId + '/articles/public?' + extra
} }
exports.getArticle = function(id) { exports.getArticle = function(id) {
return common.sendRequest({ return common.sendRequest({
method: 'GET', method: 'GET',
url: '/api/articles/' + id + '?includes=media,parent,banner,files', url: '/api/articles/public/' + id + '?includes=media,parent,banner,files',
}) })
} }

View file

@ -133,6 +133,7 @@ form {
input[type=text], input[type=text],
input[type=password], input[type=password],
input[type=datetime-local],
select, select,
textarea { textarea {
width: 100%; width: 100%;

View file

@ -13,6 +13,12 @@ const Frontpage = {
this.featured = null this.featured = null
this.links = null this.links = null
if (window.__nfpfeatured) {
this.featured = window.__nfpfeatured
}
console.log(this.featured)
if (window.__nfpdata if (window.__nfpdata
&& window.__nfplinks) { && window.__nfplinks) {
this.links = window.__nfplinks this.links = window.__nfplinks

View file

@ -76,6 +76,11 @@ exports.up = function up(knex, Promise) {
table.boolean('is_deleted') table.boolean('is_deleted')
.notNullable() .notNullable()
.default(false) .default(false)
table.timestamp('published_at')
.defaultTo(knex.fn.now())
table.boolean('is_featured')
.notNullable()
.default(false)
table.timestamps() table.timestamps()
}), }),
knex.schema.createTable('files', function(table) { knex.schema.createTable('files', function(table) {

View file

@ -27,6 +27,7 @@
<script type="text/javascript"> <script type="text/javascript">
if (localStorage.getItem('darkmode')) {document.body.className = 'darkmodeon';} if (localStorage.getItem('darkmode')) {document.body.className = 'darkmodeon';}
window.__nfptree = {{=it.tree}}; window.__nfptree = {{=it.tree}};
window.__nfpfeatured = {{=it.featured}};
window.__nfpdata = {{=it.data}}; window.__nfpdata = {{=it.data}};
window.__nfplinks = {{=it.links}}; window.__nfplinks = {{=it.links}};
</script> </script>