diff --git a/api/article/model.mjs b/api/article/model.mjs index ff846cc..f81f5ce 100644 --- a/api/article/model.mjs +++ b/api/article/model.mjs @@ -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 => { - qb.where({ id: Number(id) || 0 }) - .orWhere({ path: id }) + this.baseQueryAll(ctx, qb, where, orderBy) + 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 }) }, - 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 => { this.baseQueryAll(ctx, qb, {}, orderBy) qb.leftOuterJoin('pages', 'articles.parent_id', 'pages.id') @@ -63,6 +103,9 @@ const Article = bookshelf.createModel({ subq.where('pages.id', pageId) .orWhere('pages.parent_id', pageId) }) + if (limitToday) { + qb.where('published_at', '<=', (new Date()).toISOString()) + } qb.select('articles.*') }) .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) { return this.query(qb => { - qb.orderBy('updated_at', 'DESC') + qb.orderBy('published_at', 'DESC') + .where('published_at', '<=', (new Date()).toISOString()) }) .fetchPage({ pageSize: 10, diff --git a/api/article/routes.mjs b/api/article/routes.mjs index 6492b9b..299a856 100644 --- a/api/article/routes.mjs +++ b/api/article/routes.mjs @@ -13,14 +13,14 @@ export default class ArticleRoutes { async getAllArticles(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 */ async getAllPageArticles(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 */ @@ -30,6 +30,27 @@ export default class ArticleRoutes { 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 */ async createArticle(ctx) { await this.security.validUpdate(ctx) @@ -41,6 +62,10 @@ export default class ArticleRoutes { async updateArticle(ctx) { await this.security.validUpdate(ctx) + if (ctx.request.body.is_featured) { + await Article.setAllUnfeatured() + } + let page = await this.Article.getSingle(ctx.params.id) page.set(ctx.request.body) diff --git a/api/article/security.mjs b/api/article/security.mjs index ceb8deb..45f1b53 100644 --- a/api/article/security.mjs +++ b/api/article/security.mjs @@ -12,6 +12,8 @@ const validFields = [ 'parent_id', 'media_id', 'banner_id', + 'published_at', + 'is_featured', ] export async function ensureIncludes(ctx) { @@ -34,4 +36,8 @@ export async function validUpdate(ctx) { if (out.length > 0) { 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) + } } diff --git a/api/router.mjs b/api/router.mjs index f5ffa17..a9b0bbb 100644 --- a/api/router.mjs +++ b/api/router.mjs @@ -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)) const article = new ArticleRoutes() -router.get('/api/articles', article.getAllArticles.bind(article)) -router.get('/api/pages/:pageId/articles', article.getAllPageArticles.bind(article)) -router.get('/api/articles/:id', article.getSingleArticle.bind(article)) +router.get('/api/articles', restrict(access.Manager), article.getAllArticles.bind(article)) +router.get('/api/articles/public', article.getPublicAllArticles.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.put('/api/articles/:id', restrict(access.Manager), article.updateArticle.bind(article)) router.del('/api/articles/:id', restrict(access.Manager), article.removeArticle.bind(article)) diff --git a/api/serve.mjs b/api/serve.mjs index ac314a6..d874e9f 100644 --- a/api/serve.mjs +++ b/api/serve.mjs @@ -3,6 +3,7 @@ import defaults from './defaults.mjs' import access from './access/index.mjs' import { restrict } from './access/middleware.mjs' import { serveIndex } from './serveindex.mjs' +import config from './config.mjs' const restrictAdmin = restrict(access.Manager) @@ -36,8 +37,13 @@ export function serve(docRoot, pathname, options = {}) { if (filepath.indexOf('admin') >= 0 && (filepath.indexOf('js') >= 0 || filepath.indexOf('css') >= 0)) { - await restrictAdmin(ctx) - ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate') + if (filepath.indexOf('.map') === -1) { + 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) => { diff --git a/api/serveindex.mjs b/api/serveindex.mjs index 2b1603e..bf4e743 100644 --- a/api/serveindex.mjs +++ b/api/serveindex.mjs @@ -77,6 +77,7 @@ export async function serveIndex(ctx, path) { let tree = null let data = null let links = null + let featured = null let url = frontend + ctx.request.url let image = frontend + '/assets/img/heart.jpg' 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 } )) )) + featured = await Article.getFeatured(['files', 'media', 'banner']) + if (featured) { + featured = mapArticle(featured.toJSON()) + } + if (path === '/') { data = await Article.getFrontpageArticles(Number(ctx.query.page || '1')) @@ -108,26 +114,30 @@ export async function serveIndex(ctx, path) { if (id) { let found if (path.startsWith('/article/')) { - found = await Article.getSingle(id, ['media', 'parent', 'banner', 'files']) - found = mapArticle(found.toJSON()) + found = await Article.getSingle(id, ['media', 'parent', 'banner', 'files'], false, null, true) + if (found) { + found = mapArticle(found.toJSON()) + } data = found } else { found = await Page.getSingle(id, ['media', 'banner', 'children']) found = mapPage(found.toJSON()) data = found } - if (found.media) { - image = found.media.large_url - } else if (found.banner) { - image = found.banner.large_url - } - if (found.description) { - description = striptags(found.description) - } - if (found.parent) { - title = found.name + ' - ' + found.parent.name + ' - NFP Moe' - } else { - title = found.name + ' - NFP Moe' + if (found) { + if (found.media) { + image = found.media.large_url + } else if (found.banner) { + image = found.banner.large_url + } + if (found.description) { + description = striptags(found.description) + } + if (found.parent) { + title = found.name + ' - ' + found.parent.name + ' - NFP Moe' + } else { + title = found.name + ' - NFP Moe' + } } } } @@ -141,6 +151,7 @@ export async function serveIndex(ctx, path) { tree: JSON.stringify(tree), data: JSON.stringify(data), links: JSON.stringify(links), + featured: JSON.stringify(featured), url: url, image: image, title: title, diff --git a/app/admin/articles.js b/app/admin/articles.js index f46c2d6..af13a4c 100644 --- a/app/admin/articles.js +++ b/app/admin/articles.js @@ -70,12 +70,22 @@ const AdminArticles = { 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 [ - 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: parent.path }, parent.name)), 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')), ]), ] @@ -102,7 +112,8 @@ const AdminArticles = { m('th', 'Title'), m('th', 'Page'), m('th', 'Path'), - m('th.right', 'Updated'), + m('th.right', 'Publish'), + m('th.right', 'Other'), m('th.right', 'Actions'), ]) ), diff --git a/app/admin/articles.scss b/app/admin/articles.scss index 64a1c81..b970532 100644 --- a/app/admin/articles.scss +++ b/app/admin/articles.scss @@ -46,6 +46,16 @@ article.editarticle { } } + table { + tr.rowhidden td { + background: #e6e6e6; + } + + tr.rowfeatured td { + background: hsl(120, 60%, 85%); + } + } + h5 { margin-bottom: 20px; } diff --git a/app/admin/editarticle.js b/app/admin/editarticle.js index 22cfbda..68547b3 100644 --- a/app/admin/editarticle.js +++ b/app/admin/editarticle.js @@ -64,6 +64,8 @@ const EditArticle = { media: null, banner: null, files: [], + is_featured: false, + published_at: new Date().toISOString(), } this.editedPath = false this.loadedFroala = Froala.loadedFroala @@ -73,6 +75,7 @@ const EditArticle = { .then(function(result) { vnode.state.editedPath = true vnode.state.article = result + EditArticle.parsePublishedAt(vnode, null) document.title = 'Editing: ' + result.name + ' - Admin NFP Moe' }) .catch(function(err) { @@ -86,6 +89,7 @@ const EditArticle = { m.redraw() }) } else { + EditArticle.parsePublishedAt(vnode, new Date()) document.title = 'Create Article - Admin NFP Moe' if (vnode.state.froala) { 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) { - 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') { this.editedPath = true } else if (name === 'name' && !this.editedPath) { @@ -145,6 +157,8 @@ const EditArticle = { description: this.article.description, banner_id: this.article.banner && this.article.banner.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 { promise = Article.createArticle({ @@ -154,6 +168,8 @@ const EditArticle = { description: this.article.description, banner_id: this.article.banner && this.article.banner.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.files = vnode.state.article.files vnode.state.article = res + EditArticle.parsePublishedAt(vnode, null) } else { m.route.set('/admin/articles/' + res.id) } @@ -248,6 +265,12 @@ const EditArticle = { m('select', { 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) })), + m('label', 'Path'), + m('input', { + type: 'text', + value: this.article.path, + oninput: this.updateValue.bind(this, 'path'), + }), m('label', 'Name'), m('input', { type: 'text', @@ -266,11 +289,17 @@ const EditArticle = { }) : null ), - m('label', 'Path'), + m('label', 'Publish at'), m('input', { - type: 'text', - value: this.article.path, - oninput: this.updateValue.bind(this, 'path'), + type: 'datetime-local', + value: this.article.published_at, + 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('input', { diff --git a/app/api/article.p.js b/app/api/article.p.js index 4619968..499476a 100644 --- a/app/api/article.p.js +++ b/app/api/article.p.js @@ -16,7 +16,7 @@ exports.getAllArticlesPagination = function(options) { extra += '&includes=' + options.includes.join(',') } - return '/api/articles?' + extra + return '/api/articles/public?' + extra } exports.getAllPageArticlesPagination = function(pageId, options) { @@ -35,12 +35,12 @@ exports.getAllPageArticlesPagination = function(pageId, options) { extra += '&includes=' + options.includes.join(',') } - return '/api/pages/' + pageId + '/articles?' + extra + return '/api/pages/' + pageId + '/articles/public?' + extra } exports.getArticle = function(id) { return common.sendRequest({ method: 'GET', - url: '/api/articles/' + id + '?includes=media,parent,banner,files', + url: '/api/articles/public/' + id + '?includes=media,parent,banner,files', }) } diff --git a/app/app.scss b/app/app.scss index ebf42b9..0863cfa 100644 --- a/app/app.scss +++ b/app/app.scss @@ -133,6 +133,7 @@ form { input[type=text], input[type=password], + input[type=datetime-local], select, textarea { width: 100%; diff --git a/app/frontpage/frontpage.js b/app/frontpage/frontpage.js index 6a94dbf..5ea125a 100644 --- a/app/frontpage/frontpage.js +++ b/app/frontpage/frontpage.js @@ -13,6 +13,12 @@ const Frontpage = { this.featured = null this.links = null + if (window.__nfpfeatured) { + this.featured = window.__nfpfeatured + } + + console.log(this.featured) + if (window.__nfpdata && window.__nfplinks) { this.links = window.__nfplinks diff --git a/migrations/20190219105500_base.js b/migrations/20190219105500_base.js index f9461c0..1bc0a2e 100644 --- a/migrations/20190219105500_base.js +++ b/migrations/20190219105500_base.js @@ -76,6 +76,11 @@ exports.up = function up(knex, Promise) { table.boolean('is_deleted') .notNullable() .default(false) + table.timestamp('published_at') + .defaultTo(knex.fn.now()) + table.boolean('is_featured') + .notNullable() + .default(false) table.timestamps() }), knex.schema.createTable('files', function(table) { diff --git a/public/index.html b/public/index.html index 1a8212a..aa57c57 100644 --- a/public/index.html +++ b/public/index.html @@ -27,6 +27,7 @@