diff --git a/.eslintrc b/.eslintrc index 1b07e26..6a1e87f 100644 --- a/.eslintrc +++ b/.eslintrc @@ -7,16 +7,12 @@ } }, "extends": "eslint:recommended", - "plugins": [ - "mocha" - ], "env": { "mocha": true, "node": true, "es6": true }, "rules": { - "mocha/no-exclusive-tests": 2, "require-await": 0, "array-callback-return": 2, "block-scoped-var": 2, @@ -46,7 +42,7 @@ "no-catch-shadow": 2, "no-shadow": 2, "no-undef-init": 2, - "no-undefined": 0, + "no-undefined": 2, "no-use-before-define": 2, "no-new-require": 2, "no-sync": 2, diff --git a/api/article/model.mjs b/api/article/model.mjs new file mode 100644 index 0000000..e051820 --- /dev/null +++ b/api/article/model.mjs @@ -0,0 +1,51 @@ +import bookshelf from '../bookshelf.mjs' +import Media from '../media/model.mjs' +import Staff from '../staff/model.mjs' +import Page from '../page/model.mjs' + +/* + +Article model: +{ + name, + path, + description, + media_id, + staff_id, + parent_id, + is_deleted, + created_at, + updated_at, +} + +*/ + +const Article = bookshelf.createModel({ + tableName: 'articles', + + parent() { + return this.belongsTo(Page, 'parent_id') + }, + + banner() { + return this.belongsTo(Media, 'banner_id') + }, + + media() { + return this.belongsTo(Media, 'media_id') + }, + + staff() { + return this.belongsTo(Staff, 'staff_id') + }, +}, { + getSingle(id, withRelated = [], require = true, ctx = null) { + return this.query(qb => { + qb.where({ id: Number(id) || 0 }) + .orWhere({ path: id }) + }) + .fetch({ require, withRelated, ctx }) + }, +}) + +export default Article diff --git a/api/article/routes.mjs b/api/article/routes.mjs new file mode 100644 index 0000000..7015db5 --- /dev/null +++ b/api/article/routes.mjs @@ -0,0 +1,61 @@ +import Article from './model.mjs' +import * as security from './security.mjs' + +export default class ArticleRoutes { + constructor(opts = {}) { + Object.assign(this, { + Article: opts.Article || Article, + security: opts.security || security, + }) + } + + /** GET: /api/articles */ + async getAllArticles(ctx) { + await this.security.ensureIncludes(ctx) + + let filter = {} + if (ctx.query.tree && ctx.query.tree === 'true') { + filter.parent_id = null + } + + ctx.body = await this.Article.getAll(ctx, filter, ctx.state.filter.includes) + } + + /** GET: /api/articles/:id */ + async getSingleArticle(ctx) { + await this.security.ensureIncludes(ctx) + + ctx.body = await this.Article.getSingle(ctx.params.id, ctx.state.filter.includes, true, ctx) + } + + /** POST: /api/articles */ + async createArticle(ctx) { + await this.security.validUpdate(ctx) + + ctx.body = await this.Article.create(ctx.request.body) + } + + /** PUT: /api/articles/:id */ + async updateArticle(ctx) { + await this.security.validUpdate(ctx) + + let page = await this.Article.getSingle(ctx.params.id) + + page.set(ctx.request.body) + + await page.save() + + ctx.body = page + } + + /** DELETE: /api/articles/:id */ + async removeArticle(ctx) { + let page = await this.Article.getSingle(ctx.params.id) + + page.set({ is_deleted: true }) + + await page.save() + + ctx.status = 204 + } +} diff --git a/api/article/security.mjs b/api/article/security.mjs new file mode 100644 index 0000000..2e81824 --- /dev/null +++ b/api/article/security.mjs @@ -0,0 +1,37 @@ +import filter from '../filter.mjs' + +const requiredFields = [ + 'name', + 'path', +] + +const validFields = [ + 'name', + 'path', + 'description', + 'parent_id', + 'media_id', + 'banner_id', +] + +export async function ensureIncludes(ctx) { + let out = filter(ctx.state.filter.includes, ['staff', 'media', 'parent', 'banner']) + + if (out.length > 0) { + ctx.throw(422, `Includes had following invalid values: ${out.join(', ')}`) + } +} + +export async function validUpdate(ctx) { + requiredFields.forEach(item => { + if (ctx.request.body[item] == null) { + ctx.throw(422, `Property was missing: ${item}`) + } + }) + + let out = filter(Object.keys(ctx.request.body), validFields) + + if (out.length > 0) { + ctx.throw(422, `Body had following invalid properties: ${out.join(', ')}`) + } +} diff --git a/api/bookshelf.mjs b/api/bookshelf.mjs index 1527c7b..43915f2 100644 --- a/api/bookshelf.mjs +++ b/api/bookshelf.mjs @@ -6,11 +6,230 @@ import config from './config' import defaults from './defaults' import log from './log' -let host = config.get('knex:connection') +let connections = [config.get('knex:connection')] -log.info(host, 'Connecting to DB') +if (config.get('knex:connectionslave')) { + connections.push(config.get('knex:connectionslave')) +} -const client = knex(config.get('knex')) +let isRecovering = false +let isUrgent = false +let currentIndex = 0 +let nextIndex = currentIndex + 1 +let client +let secondaryClient + +/** + * Semi-gracefully shift the current active client connection from the + * current connected client and switch to the selected index server. + */ +async function shiftConnection(index) { + // Update our variables + isUrgent = false + currentIndex = index + + log.warn('DB: Destroying current pool') + await client.destroy() + + // Update connection settings to the new server and re-initialize the pool. + log.warn(connections[currentIndex], 'DB: Connecting to next server') + client.client.connectionSettings = connections[currentIndex] + client.initialize() +} + +/** + * Start a graceful server migration. Creates a secondary database connection + * and checks other available servers we have if they're up and can be used. + */ +async function gracefulServerMigrate() { + // Check if we're already recovering and exit then. + if (isRecovering) return + + // Urgent means we don't have ANY active database connectiong and need one quickly. + if (isUrgent) { + log.error(connections[currentIndex], `DB: Server connected to is offline.`) + } else { + log.warn(connections[currentIndex], `DB: Successfully connected to a server but its status was recovering (slave).`) + } + log.warn('DB: Attempting to gracefully connecting to different server') + isRecovering = true + + // Load up next server into a new knex connection and start connecting. + if (nextIndex === connections.length) { + nextIndex = 0 + } + secondaryClient = knex(getConfig(nextIndex, false)) + + // Keep on trying :) + while (true) { + // Make multiple attempts when we're connecting to downed or timed out databases. + let attempts = 0 + + while (attempts++ < 5) { + try { + log.warn(connections[nextIndex], `DB: Gracefully attempting to connect to server (attempt ${attempts}/5).`) + + // Connect to the database (this creates a new pool connection) and check if it's in recovery mode + let data = await secondaryClient.raw('select pg_is_in_recovery()') + + // If we reach here, we got data which means the database is up and running. + // As such, there's no need to make more attempts to same server + attempts = 6 + + // Check if it's master or if we are desperate + if (!data.rows[0].pg_is_in_recovery || isUrgent) { + // Found a viable server to connect to. Shift our active client to it. + log.info(connections[nextIndex], 'DB: Found available server, connecting to it') + await shiftConnection(nextIndex) + + // Check if we're connected to master or just a slave. + if (!data.rows[0].pg_is_in_recovery) { + // We found a master, stop recovering + log.info(connections[nextIndex], 'DB: Connection established with master.') + isRecovering = false + break + } + } + } catch (err) { + // We only care to log weird errors like postgresql errors or such. + if (err.code !== 'ECONNREFUSED' && err.code !== 'ETIMEDOUT') { + log.error({ code: err.code, message: err.message }, `DB: Unknown error while gracefully connecting to ${connections[nextIndex].host}`) + } + + // Make a next attempt after 10 seconds + await new Promise(res => setTimeout(res, 10000)) + } + } + + // Check if we found a master and break if we did. + if (isRecovering === false) break + + // Didn't find a master :( wait 60 seconds before running another attempt + log.warn(connections[nextIndex], 'DB: Connected server was deemeed unable to fit master role') + log.warn('DB: waiting 60 seconds before attempting next server') + + await new Promise(res => setTimeout(res, 60000)) + + // Move to next server + nextIndex++ + if (nextIndex === connections.length) { + nextIndex = 0 + } + + // Time to destroy our active pool on our current server and update + // the connection settings to the next server and re-initialise. + await secondaryClient.destroy() + secondaryClient.client.connectionSettings = connections[nextIndex] + secondaryClient.initialize() + } + + // We got here means we have stopped recovery process. + // Shut down the secondary knex client and destroy it and + // remove reference to it so GC can collect it eventually, hopefully. + await secondaryClient.destroy() + nextIndex = currentIndex + 1 + secondaryClient = null +} + +/** + * Event handler after our pool is created and we are creating a connection. + * Here we check if the database is in recovery mode (a.k.a. slave) and if so + * start the graceful migration to migrate back to master once it's up and running. + */ +function afterCreate(conn, done) { + conn.query('select pg_is_in_recovery()', (e, res) => { + if (e) return done(e, conn) + if (res.rows[0].pg_is_in_recovery) gracefulServerMigrate().then() + done(null, conn) + }) +} + +/** + * Event handler for when the pool gets destroyed. Here we check + * if the connection has been marked with _ending = true. + * There are some checks available we can use to check if current + * connection was abrubtly disconnected. Among those from my testing + * are as follows: + * + * conn.__knex__disposed = 'Connection ended unexpectedly' + * conn.connection._ending = true + * + * I went with connection._ending one as I feel that one's the safest. + * + */ +function beforeDestroy(conn) { + if (conn.connection._ending) { + checkActiveConnection() + } +} + +/** + * Return a valid confic for knex based on specific connection index. + * Note that we don't wanna hook into afterCreate or beforeDestroy + * in our secondary knex connection doing the recovery checking. + */ +function getConfig(index = 0, addEvents = true) { + return { + 'client': 'pg', + 'connection': connections[index], + 'migrations': { + }, + pool: { + afterCreate: addEvents && afterCreate || null, + min: 2, + max: 10, + beforeDestroy: addEvents && beforeDestroy || null, + }, + acquireConnectionTimeout: 10000, + } +} + +client = knex(getConfig(currentIndex)) + +/** + * Make sure no update or delete queries are run while we're recovering. + * This allows knex to connect to a slave and only process select queries. + * + * Note: Probably does not support complicated select queries that cause + * updates on trigger or other such things. + */ +client.on('query', data => { + if (isRecovering && data.method !== 'select') { + throw new Error('Database is in read-only mode') + } +}) + +function checkActiveConnection(attempt = 1) { + if (attempt > 5) { + isUrgent = true + return gracefulServerMigrate().then() + } + // log.info(`DB: (Attempt ${attempt}/5) Checking connection is active.`) + client.raw('select 1').catch(err => { + if (err.code === 'ECONNREFUSED') { // err.code === 'ETIMEDOUT' + isUrgent = true + return gracefulServerMigrate().then() + } + if (err) { + let wait = 3000 // err.code like '57P03' and such. + if (err.code === 'ETIMEDOUT') { + wait = 10000 + } + + log.error({ code: err.code, message: err.message }, `DB: (Attempt ${attempt}/5) Error while checking connection status`) + if (attempt < 5) { + log.warn(`DB: (Attempt ${attempt}/5) Attempting again in ${wait / 1000} seconds.`) + setTimeout(() => checkActiveConnection(attempt + 1), wait) + } else { + checkActiveConnection(attempt + 1) + } + } + }) +} + +// Only way to check startup connection errors +log.info(getConfig(currentIndex).connection, 'DB: Connecting to server') +setTimeout(() => checkActiveConnection(), 100) // Check if we're running tests while connected to // potential production environment. @@ -122,6 +341,7 @@ shelf.createModel = (attr, opts) => { !options.query._statements[0].column.indexOf || options.query._statements[0].column.indexOf('is_deleted') === -1) { // First override that is_deleted always gets filtered out. + options.query.where(`${collection.tableName()}.is_deleted`, false) } diff --git a/api/file/model.mjs b/api/file/model.mjs new file mode 100644 index 0000000..3c884cf --- /dev/null +++ b/api/file/model.mjs @@ -0,0 +1,26 @@ +import path from 'path' +import bookshelf from '../bookshelf' + +/* + +File model: +{ + filename, + filetype, + size, + staff_id, + article_id, + is_deleted, + created_at, + updated_at, +} + +*/ + +const File = bookshelf.createModel({ + tableName: 'files', +}, { + baseUrl: 'http://192.168.42.14', +}) + +export default File diff --git a/api/file/routes.mjs b/api/file/routes.mjs new file mode 100644 index 0000000..4bcd930 --- /dev/null +++ b/api/file/routes.mjs @@ -0,0 +1,49 @@ +import config from '../config' +import File from './model' +import * as multer from '../multer' +import { uploadFile } from '../media/upload' +import Jwt from '../jwt' + +export default class FileRoutes { + constructor(opts = {}) { + Object.assign(this, { + File: opts.File || File, + multer: opts.multer || multer, + jwt: opts.jwt || new Jwt(), + uploadFile: opts.uploadFile || uploadFile, + }) + } + + async upload(ctx) { + let result = await this.multer.processBody(ctx) + + let token = this.jwt.signDirect({ site: config.get('upload:name') }, config.get('upload:secret')) + + return ctx.throw(422, 'Unable to process for now') + + let file = await this.uploadFile(token, result.path) + ctx.body = await this.File.create({ + filename: result.originalname, + filetype: result.mimetype, + article_id: ctx.params.articleId, + size: result.size, + staff_id: ctx.state.user.id, + }) + } + + async getAllFiles(ctx) { + ctx.body = await this.File.getAll(ctx) + } + + async removeFile(ctx) { + let file = await this.File.getSingle(ctx.params.id) + + file.set({ + is_deleted: true, + }) + + await file.save() + + ctx.status = 200 + } +} diff --git a/api/filter.mjs b/api/filter.mjs new file mode 100644 index 0000000..4613183 --- /dev/null +++ b/api/filter.mjs @@ -0,0 +1,8 @@ + +export default function filter(input = [], itemFilter = []) { + if (input && input.length) { + let out = input.filter(item => item && itemFilter.indexOf(item) < 0) + return out + } + return [] +} diff --git a/api/index/routes.mjs b/api/index/routes.mjs new file mode 100644 index 0000000..3e875f7 --- /dev/null +++ b/api/index/routes.mjs @@ -0,0 +1,12 @@ +import dot from 'dot' +import fs from 'fs' + +export default class IndexRoutes { + constructor(opts = {}) { + this.indexBody = '' + }) + + async sendIndex(ctx) { + ctx.body = this.indexBody + } +} diff --git a/api/media/model.mjs b/api/media/model.mjs index bb22e79..f2eaf69 100644 --- a/api/media/model.mjs +++ b/api/media/model.mjs @@ -37,9 +37,21 @@ const Media = bookshelf.createModel({ large_url() { return `${Media.baseUrl}${this.get('large_image')}` }, + + link() { + return `${Media.baseUrl}${this.get('large_image')}` + }, + + url() { + return `${Media.baseUrl}${this.get('large_image')}` + }, + + thumb() { + return `${Media.baseUrl}${this.get('small_image')}` + }, }, }, { - baseUrl: 'https://cdn-nfp.global.ssl.fastly.net', + baseUrl: 'http://192.168.42.14', getSubUrl(input, size) { if (!input) return input diff --git a/api/media/resize.mjs b/api/media/resize.mjs index 2f3af0b..6c4963c 100644 --- a/api/media/resize.mjs +++ b/api/media/resize.mjs @@ -13,7 +13,10 @@ export default class Resizer { let output = this.Media.getSubUrl(input, 'small') return this.sharp(input) - .resize(300, 300) + .resize(360, 360, { + fit: sharp.fit.inside, + withoutEnlargement: true + }) .toFile(output) .then(() => output) } @@ -22,7 +25,10 @@ export default class Resizer { let output = this.Media.getSubUrl(input, 'medium') return this.sharp(input) - .resize(700, 700) + .resize(700, 700, { + fit: sharp.fit.inside, + withoutEnlargement: true + }) .toFile(output) .then(() => output) } diff --git a/api/media/routes.mjs b/api/media/routes.mjs index b8602ee..8eb886e 100644 --- a/api/media/routes.mjs +++ b/api/media/routes.mjs @@ -1,6 +1,6 @@ import config from '../config' import Media from './model' -import * as multer from './multer' +import * as multer from '../multer' import Resizer from './resize' import { uploadFile } from './upload' import Jwt from '../jwt' @@ -40,4 +40,20 @@ export default class MediaRoutes { staff_id: ctx.state.user.id, }) } + + async getAllMedia(ctx) { + ctx.body = await this.Media.getAll(ctx) + } + + async removeMedia(ctx) { + let media = await this.Media.getSingle(ctx.params.id) + + media.set({ + is_deleted: true, + }) + + await media.save() + + ctx.status = 200 + } } diff --git a/api/media/upload.mjs b/api/media/upload.mjs index 50e945a..019cec2 100644 --- a/api/media/upload.mjs +++ b/api/media/upload.mjs @@ -1,7 +1,7 @@ import http from 'http' import path from 'path' import fs from 'fs' -import Agent from 'socks5-http-client/lib/Agent' +import config from '../config' let stub @@ -31,19 +31,14 @@ export function uploadFile(token, file) { ]) const options = { - port: 2111, - hostname: 'storage01.nfp.is', + port: config.get('upload:port'), + hostname: config.get('upload:host'), method: 'POST', path: '/media?token=' + token, headers: { 'Content-Type': 'multipart/form-data; boundary=' + boundary, 'Content-Length': multipartBody.length, }, - agentClass: Agent, - agentOptions: { - socksHost: '127.0.0.1', - socksPort: 5555, - }, } const req = http.request(options) @@ -65,7 +60,10 @@ export function uploadFile(token, file) { try { output = JSON.parse(output) } catch (e) { - // Do nothing + return reject(e) + } + if (output.status) { + return reject(new Error(`Unable to upload! [${output.status}]: ${output.message}`)) } resolve(output) }) diff --git a/api/middlewares/mask.mjs b/api/middlewares/mask.mjs new file mode 100644 index 0000000..a0f1827 --- /dev/null +++ b/api/middlewares/mask.mjs @@ -0,0 +1,18 @@ +import jsonmask from 'json-mask' + +export function mask() { + return async function(ctx, next) { + await next() + + let body = ctx.body + let fields = ctx.query['fields'] || ctx.fields + + if (!body || 'object' != typeof body || !fields) return + + if (body && body.toJSON) { + body = body.toJSON() + } + + ctx.body = jsonmask.filter(body, jsonmask.compile(fields)) + } +} diff --git a/api/media/multer.mjs b/api/multer.mjs similarity index 100% rename from api/media/multer.mjs rename to api/multer.mjs diff --git a/api/page/model.mjs b/api/page/model.mjs new file mode 100644 index 0000000..480c324 --- /dev/null +++ b/api/page/model.mjs @@ -0,0 +1,66 @@ +import bookshelf from '../bookshelf.mjs' +import Media from '../media/model.mjs' +import Staff from '../staff/model.mjs' +import Article from '../article/model.mjs' + +/* + +Page model: +{ + filename, + filetype, + small_image, + medium_image, + large_image, + *small_url, + *medium_url, + *large_url, + size, + staff_id, + is_deleted, + created_at, + updated_at, +} + +*/ + +const Page = bookshelf.createModel({ + tableName: 'pages', + + banner() { + return this.belongsTo(Media, 'banner_id') + }, + + parent() { + return this.belongsTo(Page, 'parent_id') + }, + + children() { + return this.hasManyFiltered(Page, 'children', 'parent_id') + }, + + news() { + return this.hasManyFiltered(Article, 'news', 'parent_id') + .query(qb => { + qb.orderBy('id', 'desc') + }) + }, + + media() { + return this.belongsTo(Media, 'media_id') + }, + + staff() { + return this.belongsTo(Staff, 'staff_id') + }, +}, { + getSingle(id, withRelated = [], require = true, ctx = null) { + return this.query(qb => { + qb.where({ id: Number(id) || 0 }) + .orWhere({ path: id }) + }) + .fetch({ require, withRelated, ctx }) + }, +}) + +export default Page diff --git a/api/page/routes.mjs b/api/page/routes.mjs new file mode 100644 index 0000000..debdd50 --- /dev/null +++ b/api/page/routes.mjs @@ -0,0 +1,61 @@ +import Page from './model.mjs' +import * as security from './security.mjs' + +export default class PageRoutes { + constructor(opts = {}) { + Object.assign(this, { + Page: opts.Page || Page, + security: opts.security || security, + }) + } + + /** GET: /api/pages */ + async getAllPages(ctx) { + await this.security.ensureIncludes(ctx) + + let filter = {} + if (ctx.query.tree && ctx.query.tree === 'true') { + filter.parent_id = null + } + + ctx.body = await this.Page.getAll(ctx, filter, ctx.state.filter.includes) + } + + /** GET: /api/pages/:id */ + async getSinglePage(ctx) { + await this.security.ensureIncludes(ctx) + + ctx.body = await this.Page.getSingle(ctx.params.id, ctx.state.filter.includes, true, ctx) + } + + /** POST: /api/pages */ + async createPage(ctx) { + await this.security.validUpdate(ctx) + + ctx.body = await this.Page.create(ctx.request.body) + } + + /** PUT: /api/pages/:id */ + async updatePage(ctx) { + await this.security.validUpdate(ctx) + + let page = await this.Page.getSingle(ctx.params.id) + + page.set(ctx.request.body) + + await page.save() + + ctx.body = page + } + + /** DELETE: /api/pages/:id */ + async removePage(ctx) { + let page = await this.Page.getSingle(ctx.params.id) + + page.set({ is_deleted: true }) + + await page.save() + + ctx.status = 204 + } +} diff --git a/api/page/security.mjs b/api/page/security.mjs new file mode 100644 index 0000000..4f8781c --- /dev/null +++ b/api/page/security.mjs @@ -0,0 +1,37 @@ +import filter from '../filter.mjs' + +const requiredFields = [ + 'name', + 'path', +] + +const validFields = [ + 'name', + 'path', + 'parent_id', + 'description', + 'media_id', + 'banner_id', +] + +export async function ensureIncludes(ctx) { + let out = filter(ctx.state.filter.includes, ['staff', 'media', 'banner', 'news', 'news.media', 'parent', 'children']) + + if (out.length > 0) { + ctx.throw(422, `Includes had following invalid values: ${out.join(', ')}`) + } +} + +export async function validUpdate(ctx) { + requiredFields.forEach(item => { + if (ctx.request.body[item] == null) { + ctx.throw(422, `Property was missing: ${item}`) + } + }) + + let out = filter(Object.keys(ctx.request.body), validFields) + + if (out.length > 0) { + ctx.throw(422, `Body had following invalid properties: ${out.join(', ')}`) + } +} diff --git a/api/router.mjs b/api/router.mjs index 9f235c1..8154fa9 100644 --- a/api/router.mjs +++ b/api/router.mjs @@ -4,6 +4,9 @@ import Router from 'koa-router' import access from './access' import AuthRoutes from './authentication/routes' import MediaRoutes from './media/routes' +import FileRoutes from './file/routes' +import PageRoutes from './page/routes' +import ArticleRoutes from './article/routes' import { restrict } from './access/middleware' const router = new Router() @@ -14,6 +17,28 @@ router.post('/api/login', authentication.login.bind(authentication)) // API Media const media = new MediaRoutes() +router.get('/api/media', restrict(access.Manager), media.getAllMedia.bind(media)) router.post('/api/media', restrict(access.Manager), media.upload.bind(media)) +router.del('/api/media/:id', restrict(access.Manager), media.removeMedia.bind(media)) + +// API File +const file = new FileRoutes() +router.get('/api/file', restrict(access.Manager), file.getAllFiles.bind(file)) +router.post('/api/articles/:articleId/file', restrict(access.Manager), file.upload.bind(file)) +router.del('/api/file/:id', restrict(access.Manager), file.removeFile.bind(file)) + +const page = new PageRoutes() +router.get('/api/pages', page.getAllPages.bind(page)) +router.get('/api/pages/:id', page.getSinglePage.bind(page)) +router.post('/api/pages', restrict(access.Manager), page.createPage.bind(page)) +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/articles/:id', 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)) export default router diff --git a/app/_common.scss b/app/_common.scss index 1b94358..066a1cc 100644 --- a/app/_common.scss +++ b/app/_common.scss @@ -1,6 +1,6 @@ $primary-bg: #01579b; $primary-fg: white; -$primary-light-bg: #4f83cc; +$primary-light-bg: #3D77C7; /*#4f83cc;*/ $primary-light-fg: white; $primary-dark-bg: #002f6c; $primary-dark-fg: white; @@ -14,4 +14,5 @@ $secondary-dark-fg: white; $border: #ccc; $title-fg: #555; -$meta-fg: #999; \ No newline at end of file +$meta-fg: #757575; /* #999 */ +$meta-light-fg: #999999; diff --git a/app/admin/admin.scss b/app/admin/admin.scss index 814477e..1cd8c57 100644 --- a/app/admin/admin.scss +++ b/app/admin/admin.scss @@ -4,43 +4,36 @@ display: flex; flex-direction: column; background: $primary-bg; - padding: 20px; + padding: 0 20px 20px; } -article.editcat { - text-align: center; - background: white; - padding: 0 0 20px; +.admin-actions { + background: $primary-bg; + display: flex; + justify-content: center; + min-height: 37px; - header { + span { + color: $primary-fg; padding: 10px; - background: $secondary-bg; - - h1 { - color: $secondary-fg; - } + font-size: 14px; + font-weight: bold; } - fileupload { - margin: 0 20px 20px; - min-height: 100px; - } - - form { - align-items: center; - align-self: center; - padding: 20px 40px; - } - - h5 { - margin-bottom: 20px; - } - - & > .loading-spinner { - width: 240px; - height: 50px; - position: relative; + a { + padding: 10px; + text-decoration: none; + color: $secondary-light-bg; + font-size: 14px; + font-weight: bold; } } -@import 'editcat' +.fr-box, +.fr-toolbar, +.fr-box .second-toolbar { + border-radius: 0 !important; +} + +@import 'pages'; +@import 'articles'; diff --git a/app/admin/articles.js b/app/admin/articles.js new file mode 100644 index 0000000..b42e854 --- /dev/null +++ b/app/admin/articles.js @@ -0,0 +1,109 @@ +const m = require('mithril') + +const Authentication = require('../authentication') +const { getAllArticles, removeArticle } = require('../api/article') +const Dialogue = require('../widgets/dialogue') + +const AdminArticles = { + oninit: function(vnode) { + this.loading = true + this.error = '' + this.articles = [] + this.removeArticle = null + + getAllArticles() + .then(function(result) { + vnode.state.articles = result + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + confirmRemoveArticle: function(vnode) { + let removingArticle = this.removeArticle + this.removeArticle = null + this.loading = true + removeArticle(removingArticle, removingArticle.id) + .then(this.oninit.bind(this, vnode)) + .catch(function(err) { + vnode.state.error = err.message + vnode.state.loading = false + m.redraw() + }) + }, + + drawArticle: function(vnode, article) { + let parent + if (article.parent) { + parent = { + path: '/page/' + article.parent.path, + name: article.parent.name, + } + } else { + parent = { + path: '/', + name: '-- Frontpage --', + } + } + return [ + m('tr', [ + 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', m('button', { onclick: function() { vnode.state.removeArticle = article } }, 'Remove')), + ]) + ] + }, + + view: function(vnode) { + return [ + (this.loading ? + m('div.loading-spinner') + : m('div.admin-wrapper', [ + m('div.admin-actions', [ + m('span', 'Actions:'), + m(m.route.Link, { href: '/admin/articles/add' }, 'Create new article'), + ]), + m('article.editarticle', [ + m('header', m('h1', 'All articles')), + m('div.error', { + hidden: !this.error, + onclick: function() { vnode.state.error = '' } + }, this.error), + m('table', [ + m('thead', + m('tr', [ + m('th', 'Title'), + m('th', 'Page'), + m('th', 'Path'), + m('th.right', 'Updated'), + m('th.right', 'Actions'), + ]) + ), + m('tbody', this.articles.map(AdminArticles.drawArticle.bind(this, vnode))), + ]), + ]), + ]) + ), + m(Dialogue, { + hidden: vnode.state.removeArticle === null, + title: 'Delete ' + (vnode.state.removeArticle ? vnode.state.removeArticle.name : ''), + message: 'Are you sure you want to remove "' + (vnode.state.removeArticle ? vnode.state.removeArticle.name : '') + '" (' + (vnode.state.removeArticle ? vnode.state.removeArticle.path : '') + ')', + yes: 'Remove', + yesclass: 'alert', + no: 'Cancel', + noclass: 'cancel', + onyes: this.confirmRemoveArticle.bind(this, vnode), + onno: function() { vnode.state.removeArticle = null }, + }), + ] + }, +} + +module.exports = AdminArticles diff --git a/app/admin/articles.scss b/app/admin/articles.scss new file mode 100644 index 0000000..5a91ee6 --- /dev/null +++ b/app/admin/articles.scss @@ -0,0 +1,87 @@ + +article.editarticle { + text-align: center; + background: white; + padding: 0 0 20px; + + header { + padding: 10px; + background: $secondary-bg; + + h1 { + color: $secondary-fg; + } + + a { + font-size: 14px; + text-decoration: none; + margin-left: 10px; + color: $secondary-light-fg; + } + } + + fileupload { + margin: 0 0 20px; + + .inside { + height: 100px; + } + } + + fileupload.cover { + align-self: center; + min-width: 178px; + } + + form { + padding: 0 40px 20px; + + textarea { + height: 300px; + } + + .loading-spinner { + height: 300px; + position: relative; + } + } + + h5 { + margin-bottom: 20px; + } + + & > .loading-spinner { + width: 240px; + height: 50px; + position: relative; + } + + .fileupload { + align-self: center; + padding: 10.5em; + margin: 0.5em 0; + min-width: 250px; + border: none; + border: 1px solid $secondary-bg; + background: $secondary-light-bg; + color: $secondary-light-fg; + position: relative; + + input { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + opacity: 0.01; + width: 100%; + cursor: pointer; + text-indent: -9999px; + z-index: 2; + } + } +} + +table span.subarticle { + padding: 0 5px; +} diff --git a/app/admin/editarticle.js b/app/admin/editarticle.js new file mode 100644 index 0000000..7a8c8b7 --- /dev/null +++ b/app/admin/editarticle.js @@ -0,0 +1,244 @@ +const m = require('mithril') + +const Authentication = require('../authentication') +const FileUpload = require('../widgets/fileupload') +const Froala = require('./froala') +const { Tree } = require('../api/page') +const { createArticle, updateArticle, getArticle } = require('../api/article') + +const EditArticle = { + getFroalaOptions: function() { + return { + theme: 'gray', + heightMin: 150, + videoUpload: false, + imageUploadURL: '/api/media', + imageManagerLoadURL: '/api/media', + imageManagerDeleteMethod: 'DELETE', + imageManagerDeleteURL: '/api/media', + events: { + 'imageManager.beforeDeleteImage': function(img) { + this.opts.imageManagerDeleteURL = '/api/media/' + img.data('id') + }, + }, + requestHeaders: { + 'Authorization': 'Bearer ' + Authentication.getToken(), + }, + } + }, + + oninit: function(vnode) { + this.loading = m.route.param('key') !== 'add' + this.creating = m.route.param('key') === 'add' + this.loadingFile = false + this.error = '' + this.article = { + name: '', + path: '', + description: '', + media: null, + banner: null, + } + this.editedPath = false + this.froala = null + this.loadedFroala = Froala.loadedFroala + + if (m.route.param('key') !== 'add') { + getArticle(m.route.param('key')) + .then(function(result) { + vnode.state.editedPath = true + vnode.state.article = result + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + } + + if (!this.loadedFroala) { + Froala.createFroalaScript() + .then(function() { + vnode.state.loadedFroala = true + m.redraw() + }) + } + }, + + updateValue: function(name, e) { + this.article[name] = e.currentTarget.value + if (name === 'path') { + this.editedPath = true + } else if (name === 'name' && !this.editedPath) { + this.article.path = this.article.name.toLowerCase().replace(/ /g, '-') + } + }, + + updateParent: function(e) { + this.article.parent_id = Number(e.currentTarget.value) + if (this.article.parent_id === -1) { + this.article.parent_id = null + } + }, + + fileUploaded: function(type, media) { + this.article[type] = media + }, + + save: function(vnode, e) { + e.preventDefault() + if (!this.article.name) { + this.error = 'Name is missing' + } else if (!this.article.path) { + this.error = 'Path is missing' + } + if (this.error) return + + this.article.description = vnode.state.froala && vnode.state.froala.html.get() || this.article.description + + this.loading = true + + let promise + + if (this.article.id) { + promise = updateArticle(this.article.id, { + name: this.article.name, + path: this.article.path, + parent_id: this.article.parent_id, + description: this.article.description, + banner_id: this.article.banner && this.article.banner.id, + media_id: this.article.media && this.article.media.id, + }) + } else { + promise = createArticle({ + name: this.article.name, + path: this.article.path, + parent_id: this.article.parent_id, + description: this.article.description, + banner_id: this.article.banner && this.article.banner.id, + media_id: this.article.media && this.article.media.id, + }) + } + + promise.then(function(res) { + if (vnode.state.article.id) { + res.media = vnode.state.article.media + res.banner = vnode.state.article.banner + vnode.state.article = res + } else { + m.route.set('/admin/articles/' + res.id) + } + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + uploadFile(vnode, event) { + if (!event.target.files[0]) return + vnode.state.error = '' + vnode.state.loadingFile = true + }, + + getFlatTree: function() { + let out = [{id: null, name: '-- Frontpage --'}] + Tree.forEach(function(page) { + out.push({ id: page.id, name: page.name }) + if (page.children.length) { + page.children.forEach(function(sub) { + out.push({ id: sub.id, name: page.name + ' -> ' + sub.name }) + }) + } + }) + return out + }, + + view: function(vnode) { + const parents = this.getFlatTree() + return ( + this.loading ? + m('div.loading-spinner') + : m('div.admin-wrapper', [ + m('div.admin-actions', this.article.id + ? [ + m('span', 'Actions:'), + m(m.route.Link, { href: '/article/' + this.article.path }, 'View article'), + ] + : null), + m('article.editarticle', [ + m('header', m('h1', this.creating ? 'Create Article' : 'Edit ' + (this.article.name || '(untitled)'))), + m('div.error', { + hidden: !this.error, + onclick: function() { vnode.state.error = '' } + }, this.error), + m(FileUpload, { + onupload: this.fileUploaded.bind(this, 'banner'), + onerror: function(e) { vnode.state.error = e }, + media: this.article && this.article.banner, + }), + m(FileUpload, { + class: 'cover', + useimg: true, + onupload: this.fileUploaded.bind(this, 'media'), + onerror: function(e) { vnode.state.error = e }, + media: this.article && this.article.media, + }), + m('form.editarticle.content', { + onsubmit: this.save.bind(this, vnode), + }, [ + m('label', 'Parent'), + 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', 'Name'), + m('input', { + type: 'text', + value: this.article.name, + oninput: this.updateValue.bind(this, 'name'), + }), + m('label', 'Description'), + ( + this.loadedFroala ? + m('div', { + oncreate: function(div) { + vnode.state.froala = new FroalaEditor(div.dom, EditArticle.getFroalaOptions(), function() { + vnode.state.froala.html.set(vnode.state.article.description) + }) + }, + }) + : null + ), + m('label', 'Path'), + m('input', { + type: 'text', + value: this.article.path, + oninput: this.updateValue.bind(this, 'path'), + }), + m('div.loading-spinner', { hidden: this.loadedFroala }), + m('input', { + type: 'submit', + value: 'Save', + }), + ]), + m('div.fileupload', [ + 'Add file', + m('input', { + accept: '*', + type: 'file', + onchange: this.uploadFile.bind(this, vnode), + }), + (vnode.state.loading ? m('div.loading-spinner') : null), + ]), + ]), + ]) + ) + }, +} + +module.exports = EditArticle diff --git a/app/admin/editcat.js b/app/admin/editcat.js deleted file mode 100644 index 5e92386..0000000 --- a/app/admin/editcat.js +++ /dev/null @@ -1,30 +0,0 @@ -const m = require('mithril') -const Authentication = require('../authentication') -const FileUpload = require('../widgets/fileupload') - -const EditCategory = { - loading: true, - - oninit: function(vnode) { - console.log(vnode.attrs) - EditCategory.loading = !!m.route.param('id') - }, - - view: function() { - return ( - EditCategory.loading ? - m('div.loading-spinner') - : m('div.admin-wrapper', - m('article.editcat', [ - m('header', m('h1', 'Edit category')), - m(FileUpload), - m('form.editcat', [ - - ]) - ]) - ) - ) - }, -} - -module.exports = EditCategory diff --git a/app/admin/editcat.scss b/app/admin/editcat.scss deleted file mode 100644 index e69de29..0000000 diff --git a/app/admin/editpage.js b/app/admin/editpage.js new file mode 100644 index 0000000..d40e86e --- /dev/null +++ b/app/admin/editpage.js @@ -0,0 +1,213 @@ +const m = require('mithril') + +const Authentication = require('../authentication') +const FileUpload = require('../widgets/fileupload') +const Froala = require('./froala') +const { createPage, updatePage, getPage, Tree } = require('../api/page') + +const EditPage = { + getFroalaOptions: function() { + return { + theme: 'gray', + heightMin: 150, + videoUpload: false, + imageUploadURL: '/api/media', + imageManagerLoadURL: '/api/media', + imageManagerDeleteMethod: 'DELETE', + imageManagerDeleteURL: '/api/media', + events: { + 'imageManager.beforeDeleteImage': function(img) { + this.opts.imageManagerDeleteURL = '/api/media/' + img.data('id') + }, + }, + requestHeaders: { + 'Authorization': 'Bearer ' + Authentication.getToken(), + }, + } + }, + + oninit: function(vnode) { + this.loading = m.route.param('key') !== 'add' + this.creating = m.route.param('key') === 'add' + this.error = '' + this.page = { + name: '', + path: '', + description: '', + media: null, + } + this.editedPath = false + this.froala = null + this.loadedFroala = Froala.loadedFroala + + if (m.route.param('key') !== 'add') { + getPage(m.route.param('key')) + .then(function(result) { + vnode.state.editedPath = true + vnode.state.page = result + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + } + + if (!this.loadedFroala) { + Froala.createFroalaScript() + .then(function() { + vnode.state.loadedFroala = true + m.redraw() + }) + } + }, + + updateValue: function(name, e) { + this.page[name] = e.currentTarget.value + if (name === 'path') { + this.editedPath = true + } else if (name === 'name' && !this.editedPath) { + this.page.path = this.page.name.toLowerCase().replace(/ /g, '-') + } + }, + + updateParent: function(e) { + this.page.parent_id = Number(e.currentTarget.value) + if (this.page.parent_id === -1) { + this.page.parent_id = null + } + }, + + fileUploaded: function(type, media) { + this.page[type] = media + }, + + save: function(vnode, e) { + e.preventDefault() + if (!this.page.name) { + this.error = 'Name is missing' + } else if (!this.page.path) { + this.error = 'Path is missing' + } + if (this.error) return + + this.page.description = vnode.state.froala ? vnode.state.froala.html.get() : this.page.description + + this.loading = true + + let promise + + if (this.page.id) { + promise = updatePage(this.page.id, { + name: this.page.name, + path: this.page.path, + parent_id: this.page.parent_id, + description: this.page.description, + banner_id: this.page.banner && this.page.banner.id, + media_id: this.page.media && this.page.media.id, + }) + } else { + promise = createPage({ + name: this.page.name, + path: this.page.path, + parent_id: this.page.parent_id, + description: this.page.description, + banner_id: this.page.banner && this.page.banner.id, + media_id: this.page.media && this.page.media.id, + }) + } + + promise.then(function(res) { + if (vnode.state.page.id) { + res.media = vnode.state.page.media + res.banner = vnode.state.page.banner + vnode.state.page = res + } else { + m.route.set('/admin/pages/' + res.id) + } + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + view: function(vnode) { + const parents = [{id: null, name: '-- Frontpage --'}].concat(Tree).filter(function (page) { return !vnode.state.page || page.id !== vnode.state.page.id}) + return ( + this.loading ? + m('div.loading-spinner') + : m('div.admin-wrapper', [ + m('div.admin-actions', this.page.id + ? [ + m('span', 'Actions:'), + m(m.route.Link, { href: '/page/' + this.page.path }, 'View page'), + ] + : null), + m('article.editpage', [ + m('header', m('h1', this.creating ? 'Create Page' : 'Edit ' + (this.page.name || '(untitled)'))), + m('div.error', { + hidden: !this.error, + onclick: function() { vnode.state.error = '' } + }, this.error), + m(FileUpload, { + onupload: this.fileUploaded.bind(this, 'banner'), + onerror: function(e) { vnode.state.error = e }, + media: this.page && this.page.banner, + }), + m(FileUpload, { + class: 'cover', + useimg: true, + onupload: this.fileUploaded.bind(this, 'media'), + onerror: function(e) { vnode.state.error = e }, + media: this.page && this.page.media, + }), + m('form.editpage.content', { + onsubmit: this.save.bind(this, vnode), + }, [ + m('label', 'Parent'), + m('select', { + onchange: this.updateParent.bind(this), + }, parents.map(function(item) { return m('option', { value: item.id || -1, selected: item.id === vnode.state.page.parent_id }, item.name) })), + m('label', 'Name'), + m('input', { + type: 'text', + value: this.page.name, + oninput: this.updateValue.bind(this, 'name'), + }), + m('label', 'Description'), + ( + this.loadedFroala ? + m('div', { + oncreate: function(div) { + vnode.state.froala = new FroalaEditor(div.dom, EditPage.getFroalaOptions(), function() { + vnode.state.froala.html.set(vnode.state.page.description) + }) + }, + }) + : null + ), + m('label', 'Path'), + m('input', { + type: 'text', + value: this.page.path, + oninput: this.updateValue.bind(this, 'path'), + }), + m('div.loading-spinner', { hidden: this.loadedFroala }), + m('input', { + type: 'submit', + value: 'Save', + }), + ]), + ]), + ]) + ) + }, +} + +module.exports = EditPage diff --git a/app/admin/froala.js b/app/admin/froala.js new file mode 100644 index 0000000..fc1b214 --- /dev/null +++ b/app/admin/froala.js @@ -0,0 +1,46 @@ +const Froala = { + files: [ + { type: 'css', url: 'https://cdn.jsdelivr.net/npm/froala-editor@3.0.4/css/froala_editor.pkgd.min.css' }, + { type: 'css', url: 'https://cdn.jsdelivr.net/npm/froala-editor@3.0.4/css/themes/gray.min.css' }, + { type: 'js', url: 'https://cdn.jsdelivr.net/npm/froala-editor@3.0.4/js/froala_editor.pkgd.min.js' }, + ], + loadedFiles: 0, + loadedFroala: false, + + checkLoadedAll: function(res) { + if (Froala.loadedFiles < Froala.files.length) { + return + } + Froala.loadedFroala = true + res() + }, + + createFroalaScript: function() { + if (Froala.loadedFroala) return Promise.resolve() + return new Promise(function(res) { + let onload = function() { + Froala.loadedFiles++ + Froala.checkLoadedAll(res) + } + let head = document.getElementsByTagName('head')[0] + + for (var i = 0; i < Froala.files.length; i++) { + let element + if (Froala.files[i].type === 'css') { + element = document.createElement('link') + element.setAttribute('rel', 'stylesheet') + element.setAttribute('type', 'text/css') + element.setAttribute('href', Froala.files[i].url) + } else { + element = document.createElement('script') + element.setAttribute('type','text/javascript') + element.setAttribute('src', Froala.files[i].url) + } + element.onload = onload + head.insertBefore(element, head.firstChild) + } + }) + }, +} + +module.exports = Froala diff --git a/app/admin/pages.js b/app/admin/pages.js new file mode 100644 index 0000000..a42949f --- /dev/null +++ b/app/admin/pages.js @@ -0,0 +1,114 @@ +const m = require('mithril') + +const Authentication = require('../authentication') +const { getAllPages, removePage } = require('../api/page') +const Dialogue = require('../widgets/dialogue') + +const AdminPages = { + parseTree: function(pages) { + let map = new Map() + for (let i = 0; i < pages.length; i++) { + pages[i].children = [] + map.set(pages[i].id, pages[i]) + } + for (let i = 0; i < pages.length; i++) { + if (pages[i].parent_id && map.has(pages[i].parent_id)) { + map.get(pages[i].parent_id).children.push(pages[i]) + pages.splice(i, 1) + i-- + } + } + return pages + }, + + oninit: function(vnode) { + this.loading = true + this.error = '' + this.pages = [] + this.removePage = null + + getAllPages() + .then(function(result) { + vnode.state.pages = AdminPages.parseTree(result) + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + confirmRemovePage: function(vnode) { + let removingPage = this.removePage + this.removePage = null + this.loading = true + removePage(removingPage, removingPage.id) + .then(this.oninit.bind(this, vnode)) + .catch(function(err) { + vnode.state.error = err.message + vnode.state.loading = false + m.redraw() + }) + }, + + drawPage: function(vnode, page) { + return [ + m('tr', [ + m('td', [ + page.parent_id ? m('span.subpage', '| >') : null, + m(m.route.Link, { href: '/admin/pages/' + page.id }, page.name), + ]), + m('td', m(m.route.Link, { href: '/page/' + page.path }, '/page/' + page.path)), + m('td.right', page.updated_at.replace('T', ' ').split('.')[0]), + m('td.right', m('button', { onclick: function() { vnode.state.removePage = page } }, 'Remove')), + ]) + ].concat(page.children.map(AdminPages.drawPage.bind(this, vnode))) + }, + + view: function(vnode) { + return [ + (this.loading ? + m('div.loading-spinner') + : m('div.admin-wrapper', [ + m('div.admin-actions', [ + m('span', 'Actions:'), + m(m.route.Link, { href: '/admin/pages/add' }, 'Create new page'), + ]), + m('article.editpage', [ + m('header', m('h1', 'All pages')), + m('div.error', { + hidden: !this.error, + onclick: function() { vnode.state.error = '' } + }, this.error), + m('table', [ + m('thead', + m('tr', [ + m('th', 'Title'), + m('th', 'Path'), + m('th.right', 'Updated'), + m('th.right', 'Actions'), + ]) + ), + m('tbody', this.pages.map(AdminPages.drawPage.bind(this, vnode))), + ]), + ]), + ]) + ), + m(Dialogue, { + hidden: vnode.state.removePage === null, + title: 'Delete ' + (vnode.state.removePage ? vnode.state.removePage.name : ''), + message: 'Are you sure you want to remove "' + (vnode.state.removePage ? vnode.state.removePage.name : '') + '" (' + (vnode.state.removePage ? vnode.state.removePage.path : '') + ')', + yes: 'Remove', + yesclass: 'alert', + no: 'Cancel', + noclass: 'cancel', + onyes: this.confirmRemovePage.bind(this, vnode), + onno: function() { vnode.state.removePage = null }, + }), + ] + }, +} + +module.exports = AdminPages diff --git a/app/admin/pages.scss b/app/admin/pages.scss new file mode 100644 index 0000000..2fb58bb --- /dev/null +++ b/app/admin/pages.scss @@ -0,0 +1,66 @@ + +article.editpage { + text-align: center; + background: white; + padding: 0 0 20px; + + header { + padding: 10px; + background: $secondary-bg; + + h1 { + color: $secondary-fg; + } + + a { + font-size: 14px; + text-decoration: none; + margin-left: 10px; + color: $secondary-light-fg; + } + } + + fileupload { + margin: 0 0 20px; + + .inside { + height: 100px; + } + } + + fileupload.cover { + align-self: center; + min-width: 178px; + + .display { + background-size: auto 100%; + } + } + + form { + padding: 0 40px 20px; + + textarea { + height: 300px; + } + + .loading-spinner { + height: 300px; + position: relative; + } + } + + h5 { + margin-bottom: 20px; + } + + & > .loading-spinner { + width: 240px; + height: 50px; + position: relative; + } +} + +table span.subpage { + padding: 0 5px; +} diff --git a/app/api/article.js b/app/api/article.js new file mode 100644 index 0000000..fde8c23 --- /dev/null +++ b/app/api/article.js @@ -0,0 +1,38 @@ +const { sendRequest } = require('./common') + +exports.createArticle = function(body) { + return sendRequest({ + method: 'POST', + url: '/api/articles', + body: body, + }) +} + +exports.updateArticle = function(id, body) { + return sendRequest({ + method: 'PUT', + url: '/api/articles/' + id, + body: body, + }) +} + +exports.getAllArticles = function() { + return sendRequest({ + method: 'GET', + url: '/api/articles?includes=parent', + }) +} + +exports.getArticle = function(id) { + return sendRequest({ + method: 'GET', + url: '/api/articles/' + id + '?includes=media,parent,banner', + }) +} + +exports.removeArticle = function(article, id) { + return sendRequest({ + method: 'DELETE', + url: '/api/articles/' + id, + }) +} diff --git a/app/api/common.js b/app/api/common.js index 5c31f95..315f262 100644 --- a/app/api/common.js +++ b/app/api/common.js @@ -15,6 +15,9 @@ exports.sendRequest = function(options) { Authentication.clearToken() m.route.set('/login', { redirect: m.route.get() }) } + if (error.response && error.response.status) { + return Promise.reject(error.response) + } return Promise.reject(error) }) } diff --git a/app/api/media.js b/app/api/media.js index fdfdf58..c790f12 100644 --- a/app/api/media.js +++ b/app/api/media.js @@ -8,6 +8,6 @@ exports.uploadMedia = function(file) { return sendRequest({ method: 'POST', url: '/api/media', - data: formData, + body: formData, }) } diff --git a/app/api/page.js b/app/api/page.js new file mode 100644 index 0000000..097857a --- /dev/null +++ b/app/api/page.js @@ -0,0 +1,99 @@ +const { sendRequest } = require('./common') + +const Tree = [] + +exports.Tree = Tree + +exports.createPage = function(body) { + return sendRequest({ + method: 'POST', + url: '/api/pages', + body: body, + }).then(function(res) { + res.children = [] + if (!res.parent_id) { + Tree.push(res) + } else { + for (let i = 0; i < Tree.length; i++) { + if (Tree[i].id === res.parent_id) { + Tree[i].children.push(res) + break + } + } + } + return res + }) +} + +exports.getTree = function(body) { + return sendRequest({ + method: 'GET', + url: '/api/pages?tree=true&includes=children&fields=id,name,path,children(id,name,path)', + }) +} + +exports.updatePage = function(id, body) { + return sendRequest({ + method: 'PUT', + url: '/api/pages/' + id, + body: body, + }).then(function(res) { + for (let i = 0; i < Tree.length; i++) { + if (Tree[i].id === res.id) { + res.children = Tree[i].children + Tree[i] = res + break + } else if (Tree[i].id === res.parent_id) { + for (let x = 0; x < Tree[i].children.length; x++) { + if (Tree[i].children[x].id === res.id) { + res.children = Tree[i].children[x].children + Tree[i].children[x] = res + break + } + } + break + } + } + if (!res.children) { + res.children = [] + } + return res + }) +} + +exports.getAllPages = function() { + return sendRequest({ + method: 'GET', + url: '/api/pages', + }) +} + +exports.getPage = function(id) { + return sendRequest({ + method: 'GET', + url: '/api/pages/' + id + '?includes=media,banner,children,news,news.media', + }) +} + +exports.removePage = function(page, id) { + return sendRequest({ + method: 'DELETE', + url: '/api/pages/' + id, + }).then(function() { + for (let i = 0; i < Tree.length; i++) { + if (Tree[i].id === page.id) { + Tree.splice(i, 1) + break + } else if (Tree[i].id === page.parent_id) { + for (let x = 0; x < Tree[i].children.length; x++) { + if (Tree[i].children[x].id === page.id) { + Tree[i].children.splice(x, 1) + break + } + } + break + } + } + return null + }) +} diff --git a/app/app.scss b/app/app.scss index 0052cbb..6e0ac0e 100644 --- a/app/app.scss +++ b/app/app.scss @@ -107,7 +107,133 @@ article { } } +form { + display: flex; + flex-direction: column; + align-items: stretch; + + label { + text-align: left; + font-weight: bold; + font-size: 0.8em; + padding: 5px 0 3px; + } + + input[type=text], + input[type=password], + select, + textarea { + width: 100%; + font-size: 1em; + padding: 0.5em; + margin: 0 0 0.5em; + background: white; + border: 1px solid #333; + color: black; + outline: none; + + &:hover, + &:focus { + border-color: $secondary-bg; + } + } + + input[type=submit] { + align-self: center; + padding: 0.5em; + margin: 0.5em 0; + min-width: 150px; + border: none; + border: 1px solid $secondary-bg; + background: $secondary-light-bg; + color: $secondary-light-fg; + + &:hover { + background: $secondary-dark-bg; + color: $secondary-dark-fg; + } + } +} + +.fr-view { + .clearfix::after{clear:both;display:block;content:"";height:0}.hide-by-clipping{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}img.fr-rounded,.fr-img-caption.fr-rounded img{border-radius:10px;-moz-border-radius:10px;-webkit-border-radius:10px;-moz-background-clip:padding;-webkit-background-clip:padding-box;background-clip:padding-box}img.fr-bordered,.fr-img-caption.fr-bordered img{border:solid 5px #CCC}img.fr-bordered{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}.fr-img-caption.fr-bordered img{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.fr-view{word-wrap:break-word}.fr-view span[style~="color:"] a{color:inherit}.fr-view strong{font-weight:700}.fr-view table{border:none;border-collapse:collapse;empty-cells:show;max-width:100%}.fr-view table td{min-width:5px}.fr-view table.fr-dashed-borders td,.fr-view table.fr-dashed-borders th{border-style:dashed}.fr-view table.fr-alternate-rows tbody tr:nth-child(2n){background:whitesmoke}.fr-view table td,.fr-view table th{border:1px solid #DDD}.fr-view table td:empty,.fr-view table th:empty{height:20px}.fr-view table td.fr-highlighted,.fr-view table th.fr-highlighted{border:1px double red}.fr-view table td.fr-thick,.fr-view table th.fr-thick{border-width:2px}.fr-view table th{background:#ececec}.fr-view hr{clear:both;user-select:none;-o-user-select:none;-moz-user-select:none;-khtml-user-select:none;-webkit-user-select:none;-ms-user-select:none;page-break-after:always}.fr-view .fr-file{position:relative}.fr-view .fr-file::after{position:relative;content:"\1F4CE";font-weight:normal}.fr-view pre{white-space:pre-wrap;word-wrap:break-word;overflow:visible}.fr-view[dir="rtl"] blockquote{border-left:none;border-right:solid 2px #5E35B1;margin-right:0;padding-right:5px;padding-left:0}.fr-view[dir="rtl"] blockquote blockquote{border-color:#00BCD4}.fr-view[dir="rtl"] blockquote blockquote blockquote{border-color:#43A047}.fr-view blockquote{border-left:solid 2px #5E35B1;margin-left:0;padding-left:5px;color:#5E35B1}.fr-view blockquote blockquote{border-color:#00BCD4;color:#00BCD4}.fr-view blockquote blockquote blockquote{border-color:#43A047;color:#43A047}.fr-view span.fr-emoticon{font-weight:normal;font-family:"Apple Color Emoji","Segoe UI Emoji","NotoColorEmoji","Segoe UI Symbol","Android Emoji","EmojiSymbols";display:inline;line-height:0}.fr-view span.fr-emoticon.fr-emoticon-img{background-repeat:no-repeat !important;font-size:inherit;height:1em;width:1em;min-height:20px;min-width:20px;display:inline-block;margin:-.1em .1em .1em;line-height:1;vertical-align:middle}.fr-view .fr-text-gray{color:#AAA !important}.fr-view .fr-text-bordered{border-top:solid 1px #222;border-bottom:solid 1px #222;padding:10px 0}.fr-view .fr-text-spaced{letter-spacing:1px}.fr-view .fr-text-uppercase{text-transform:uppercase}.fr-view .fr-class-highlighted{background-color:#ffff00}.fr-view .fr-class-code{border-color:#cccccc;border-radius:2px;-moz-border-radius:2px;-webkit-border-radius:2px;-moz-background-clip:padding;-webkit-background-clip:padding-box;background-clip:padding-box;background:#f5f5f5;padding:10px;font-family:"Courier New", Courier, monospace}.fr-view .fr-class-transparency{opacity:0.5}.fr-view img{position:relative;max-width:100%}.fr-view img.fr-dib{margin:5px auto;display:block;float:none;vertical-align:top}.fr-view img.fr-dib.fr-fil{margin-left:0;text-align:left}.fr-view img.fr-dib.fr-fir{margin-right:0;text-align:right}.fr-view img.fr-dii{display:inline-block;float:none;vertical-align:bottom;margin-left:5px;margin-right:5px;max-width:calc(100% - (2 * 5px))}.fr-view img.fr-dii.fr-fil{float:left;margin:5px 5px 5px 0;max-width:calc(100% - 5px)}.fr-view img.fr-dii.fr-fir{float:right;margin:5px 0 5px 5px;max-width:calc(100% - 5px)}.fr-view span.fr-img-caption{position:relative;max-width:100%}.fr-view span.fr-img-caption.fr-dib{margin:5px auto;display:block;float:none;vertical-align:top}.fr-view span.fr-img-caption.fr-dib.fr-fil{margin-left:0;text-align:left}.fr-view span.fr-img-caption.fr-dib.fr-fir{margin-right:0;text-align:right}.fr-view span.fr-img-caption.fr-dii{display:inline-block;float:none;vertical-align:bottom;margin-left:5px;margin-right:5px;max-width:calc(100% - (2 * 5px))}.fr-view span.fr-img-caption.fr-dii.fr-fil{float:left;margin:5px 5px 5px 0;max-width:calc(100% - 5px)}.fr-view span.fr-img-caption.fr-dii.fr-fir{float:right;margin:5px 0 5px 5px;max-width:calc(100% - 5px)}.fr-view .fr-video{text-align:center;position:relative}.fr-view .fr-video.fr-rv{padding-bottom:56.25%;padding-top:30px;height:0;overflow:hidden}.fr-view .fr-video.fr-rv>iframe,.fr-view .fr-video.fr-rv object,.fr-view .fr-video.fr-rv embed{position:absolute !important;top:0;left:0;width:100%;height:100%}.fr-view .fr-video>*{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;max-width:100%;border:none}.fr-view .fr-video.fr-dvb{display:block;clear:both}.fr-view .fr-video.fr-dvb.fr-fvl{text-align:left}.fr-view .fr-video.fr-dvb.fr-fvr{text-align:right}.fr-view .fr-video.fr-dvi{display:inline-block}.fr-view .fr-video.fr-dvi.fr-fvl{float:left}.fr-view .fr-video.fr-dvi.fr-fvr{float:right}.fr-view a.fr-strong{font-weight:700}.fr-view a.fr-green{color:green}.fr-view .fr-img-caption{text-align:center}.fr-view .fr-img-caption .fr-img-wrap{padding:0;display:inline-block;margin:auto;text-align:center;width:100%}.fr-view .fr-img-caption .fr-img-wrap img{display:block;margin:auto;width:100%}.fr-view .fr-img-caption .fr-img-wrap>span{margin:auto;display:block;padding:5px 5px 10px;font-size:14px;font-weight:initial;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;-webkit-opacity:0.9;-moz-opacity:0.9;opacity:0.9;-ms-filter:"progid:DXImageTransform.Microsoft.Alpha(Opacity=0)";width:100%;text-align:center}.fr-view button.fr-rounded,.fr-view input.fr-rounded,.fr-view textarea.fr-rounded{border-radius:10px;-moz-border-radius:10px;-webkit-border-radius:10px;-moz-background-clip:padding;-webkit-background-clip:padding-box;background-clip:padding-box}.fr-view button.fr-large,.fr-view input.fr-large,.fr-view textarea.fr-large{font-size:24px}a.fr-view.fr-strong{font-weight:700}a.fr-view.fr-green{color:green}img.fr-view{position:relative;max-width:100%}img.fr-view.fr-dib{margin:5px auto;display:block;float:none;vertical-align:top}img.fr-view.fr-dib.fr-fil{margin-left:0;text-align:left}img.fr-view.fr-dib.fr-fir{margin-right:0;text-align:right}img.fr-view.fr-dii{display:inline-block;float:none;vertical-align:bottom;margin-left:5px;margin-right:5px;max-width:calc(100% - (2 * 5px))}img.fr-view.fr-dii.fr-fil{float:left;margin:5px 5px 5px 0;max-width:calc(100% - 5px)}img.fr-view.fr-dii.fr-fir{float:right;margin:5px 0 5px 5px;max-width:calc(100% - 5px)}span.fr-img-caption.fr-view{position:relative;max-width:100%}span.fr-img-caption.fr-view.fr-dib{margin:5px auto;display:block;float:none;vertical-align:top}span.fr-img-caption.fr-view.fr-dib.fr-fil{margin-left:0;text-align:left}span.fr-img-caption.fr-view.fr-dib.fr-fir{margin-right:0;text-align:right}span.fr-img-caption.fr-view.fr-dii{display:inline-block;float:none;vertical-align:bottom;margin-left:5px;margin-right:5px;max-width:calc(100% - (2 * 5px))}span.fr-img-caption.fr-view.fr-dii.fr-fil{float:left;margin:5px 5px 5px 0;max-width:calc(100% - 5px)}span.fr-img-caption.fr-view.fr-dii.fr-fir{float:right;margin:5px 0 5px 5px;max-width:calc(100% - 5px)} + h1, h2, h3, h4, h5, h6, p, dl, ol, ul { + margin: 0 0 1em !important; + } + + a { color: $secondary-dark-bg; } + dt { font-weight: bold; } + ol { list-style-type: decimal; padding-left: 40px; } + ul { list-style-type: disc; padding-left: 40px; } + h1 { font-size: 1.8em; font-weight: bold; } + h2 { font-size: 1.6em; font-weight: bold; } + h3 { font-size: 1.4em; font-weight: bold; } + h4 { font-size: 1.2em; font-weight: bold; } + h5 { font-size: 1.0em; font-weight: bold; } + h6 { font-size: 0.8em; font-weight: bold; } + hr { width: 100%; } + +} + +$bordercolor: $primary-bg; +$headcolor: $primary-light-bg; +$headtext: $primary-light-fg; + +table { + width: calc(100% - 20px); + margin: 10px; + border: solid 1px $bordercolor; + border-collapse: collapse; + border-spacing: 0; + font-size: 0.8em; +} +table thead th { + background-color: $headcolor; + border: solid 1px $bordercolor; + color: $headtext; + padding: 10px; + text-align: left; +} +table tbody td { + text-align: left; + border: solid 1px $bordercolor; + color: #333; + padding: 10px; +} +table a, +table a:visited, +table a:hover { + text-decoration: none; + color: $secondary-bg; + font-weight: bold; +} + +table button { + color: $secondary-bg; + background: transparent; + border: 1px solid $secondary-bg; +} + +table td.right, +table th.right { + text-align: right; +} + +.floating-container { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: #00000099; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + @import 'menu/menu'; @import 'login/login'; @import 'admin/admin'; @import 'widgets/common'; +@import 'pages/page'; diff --git a/app/authentication.js b/app/authentication.js index 40f080e..12fa25c 100644 --- a/app/authentication.js +++ b/app/authentication.js @@ -1,5 +1,4 @@ const m = require('mithril') -const jwt = require('jsonwebtoken') const storageName = 'logintoken' const loadingListeners = [] @@ -20,7 +19,7 @@ const Authentication = { updateToken: function(token) { if (!token) return Authentication.clearToken() localStorage.setItem(storageName, token) - Authentication.currentUser = jwt.decode(token) + Authentication.currentUser = JSON.parse(atob(token.split('.')[1])) }, clearToken: function() { diff --git a/app/index.js b/app/index.js index 8deae3f..46f0c32 100644 --- a/app/index.js +++ b/app/index.js @@ -1,13 +1,17 @@ const m = require('mithril') -m.route.prefix('') +m.route.prefix = '' const Authentication = require('./authentication') const Menu = require('./menu/menu') const Frontpage = require('./frontpage/frontpage') const Login = require('./login/login') const Logout = require('./login/logout') -const EditCategory = require('./admin/editcat') +const EditPage = require('./admin/editpage') +const Page = require('./pages/page') +const AdminPages = require('./admin/pages') +const AdminArticles = require('./admin/articles') +const EditArticle = require('./admin/editarticle') const menuRoot = document.getElementById('nav') const mainRoot = document.getElementById('main') @@ -16,6 +20,10 @@ m.route(mainRoot, '/', { '/': Frontpage, '/login': Login, '/logout': Logout, - '/admin/addcat': EditCategory, + '/page/:key': Page, + '/admin/pages': AdminPages, + '/admin/pages/:key': EditPage, + '/admin/articles': AdminArticles, + '/admin/articles/:key': EditArticle, }) m.mount(menuRoot, Menu) diff --git a/app/login/login.js b/app/login/login.js index 854b138..85d4a2f 100644 --- a/app/login/login.js +++ b/app/login/login.js @@ -16,7 +16,7 @@ const Login = { 'theme': 'dark', 'onsuccess': Login.onGoogleSuccess, 'onfailure': Login.onGoogleFailure, - }); + }) }, onGoogleSuccess: function(googleUser) { @@ -26,15 +26,15 @@ const Login = { m.request({ method: 'POST', url: '/api/login', - data: { token: googleUser.Zi.access_token }, + body: { token: googleUser.Zi.access_token }, }) .then(function(result) { Authentication.updateToken(result.token) m.route.set(Login.redirect || '/') }) .catch(function(error) { - Login.error = 'Error while logging into NFP! ' + error.code + ': ' + error.response.message - let auth2 = gapi.auth2.getAuthInstance(); + Login.error = 'Error while logging into NFP! ' + error.status + ': ' + error.message + let auth2 = gapi.auth2.getAuthInstance() return auth2.signOut() }) .then(function () { @@ -44,9 +44,11 @@ const Login = { }, onGoogleFailure: function(error) { - Login.error = 'Error while logging into Google: ' + error - m.redraw() - Authentication.createGoogleScript() + if (error.error !== 'popup_closed_by_user' && error.error !== 'popup_blocked_by_browser') { + console.error(error) + Login.error = 'Error while logging into Google: ' + error.error + m.redraw() + } }, oninit: function(vnode) { @@ -75,11 +77,11 @@ const Login = { Login.error ? m('div.error', Login.error) : null, Login.loading ? m('div.loading-spinner') : null, m('div#googlesignin', { hidden: Login.loading }, m('div.loading-spinner')), - ]) + ]), ]), ]), ] - } + }, } module.exports = Login diff --git a/app/login/logout.js b/app/login/logout.js index 2f0fca3..f825992 100644 --- a/app/login/logout.js +++ b/app/login/logout.js @@ -17,6 +17,8 @@ const Logout = { .then(function() { Authentication.clearToken() m.route.set('/') + }, function(err) { + console.log('unable to logout:', err) }) }, diff --git a/app/menu/menu.js b/app/menu/menu.js index 682dd20..2f7e4cc 100644 --- a/app/menu/menu.js +++ b/app/menu/menu.js @@ -1,18 +1,36 @@ const m = require('mithril') const Authentication = require('../authentication') +const { getAllPages, Tree, getTree } = require('../api/page') const Menu = { currentActive: 'home', + error: '', + loading: false, onbeforeupdate: function() { let currentPath = m.route.get() if (currentPath === '/') Menu.currentActive = 'home' else if (currentPath === '/login') Menu.currentActive = 'login' - else Menu.currentActive = 'none' + else Menu.currentActive = currentPath }, - oninit: function() { + oninit: function(vnode) { Menu.onbeforeupdate() + + Menu.loading = true + + getTree() + .then(function(results) { + Tree.splice(0, Tree.Length) + Tree.push.apply(Tree, results) + }) + .catch(function(err) { + Menu.error = err.message + }) + .then(function() { + Menu.loading = false + m.redraw() + }) }, view: function() { @@ -21,25 +39,39 @@ const Menu = { m('h2', 'NFP Moe'), m('aside', Authentication.currentUser ? [ m('p', 'Welcome ' + Authentication.currentUser.email), - (Authentication.currentUser.level >= 100 ? - m('a[href=/admin/addcat]', { oncreate: m.route.link }, 'Create category') + (Authentication.currentUser.level >= 100 + ? [ + m(m.route.Link, { href: '/admin/pages' }, 'Pages'), + m(m.route.Link, { href: '/admin/articles' }, 'Articles'), + ] : null ), - m('a[href=/logout]', { oncreate: m.route.link }, 'Logout') + m(m.route.Link, { href: '/logout' }, 'Logout') ] : [ - m('a[href=/login]', { oncreate: m.route.link }, 'Login') + m(m.route.Link, { href: '/login' }, 'Login') ]) ]), m('nav', [ - m('a[href=/]', { - class: Menu.currentActive === 'home' ? 'active' : '', - oncreate: m.route.link + m(m.route.Link, { + href: '/', + class: Menu.currentActive === 'home' ? 'active' : '', }, 'Home'), - m('a[href=/articles]', { - class: Menu.currentActive === 'articles' ? 'active' : '', - oncreate: m.route.link - }, 'Articles'), + Menu.loading ? m('div.loading-spinner') : Tree.map(function(page) { + if (page.children.length) { + return m('div.hassubmenu', [ + m(m.route.Link, { + href: '/page/' + page.path, + class: Menu.currentActive === ('/page/' + page.path) ? 'active' : '', + }, page.name) + ]) + } + return m(m.route.Link, { + href: '/page/' + page.path, + class: Menu.currentActive === ('/page/' + page.path) ? 'active' : '', + }, page.name) + }), ]), + Menu.error ? m('div.menuerror', Menu.error) : null, ] } } diff --git a/app/menu/menu.scss b/app/menu/menu.scss index 19c0beb..ab0f438 100644 --- a/app/menu/menu.scss +++ b/app/menu/menu.scss @@ -25,7 +25,7 @@ p { font-size: 0.8em; - color: $meta-fg; + color: $meta-light-fg; padding-bottom: 5px; } @@ -45,6 +45,12 @@ background: $primary-light-bg; color: $primary-light-fg; + .hassubmenu { + flex-grow: 2; + flex-basis: 0; + display: flex; + } + a, a:visited { flex-grow: 2; flex-basis: 0; @@ -62,4 +68,18 @@ } } } + + .loading-spinner { + position: relative; + flex-grow: 2; + flex-basis: 0; + } + + .menuerror { + background: $primary-bg; + text-align: center; + padding: 10px; + color: #FFC7C7; + font-weight: bold; + } } diff --git a/app/pages/page.js b/app/pages/page.js new file mode 100644 index 0000000..df5ee87 --- /dev/null +++ b/app/pages/page.js @@ -0,0 +1,87 @@ +const m = require('mithril') +const { getPage } = require('../api/page') +const Authentication = require('../authentication') +const Newsentry = require('../widgets/newsentry') + +const Page = { + oninit: function(vnode) { + this.path = m.route.param('key') + this.error = '' + this.page = { + id: 0, + name: '', + path: '', + description: '', + media: null, + } + + this.loading = true + + getPage(this.path) + .then(function(result) { + vnode.state.page = result + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + view: function(vnode) { + return ( + this.loading ? + m('div.loading-spinner') + : m('article.page', [ + this.page.banner ? m('.div.page-banner', { style: { 'background-image': 'url(' + this.page.banner.url + ')' } } ) : null, + m('header', m('h1', this.page.name)), + m('.container', { + class: this.page.children.length ? 'multi' : '', + }, [ + this.page.children.length + ? m('aside.sidebar', [ + m('h4', 'View ' + this.page.name + ':'), + this.page.children.map(function(page) { + return m(m.route.Link, { href: '/page/' + page.path, }, page.name) + }), + ]) + : null, + this.page.description + ? m('.fr-view', [ + this.page.media ? m('img.page-cover', { src: this.page.media.url } ) : null, + m.trust(this.page.description), + this.page.news.length && this.page.description + ? m('aside.news', [ + m('h4', 'Latest updates under ' + this.page.name + ':'), + this.page.news.map(function(article) { + return m(Newsentry, article) + }), + ]) + : null + ]) + : null, + ]), + this.page.news.length && !this.page.description + ? m('aside.news', { + class: this.page.description ? '' : 'single' + }, [ + m('h4', 'Latest updates under ' + this.page.name + ':'), + this.page.news.map(function(article) { + return m(Newsentry, article) + }), + ]) + : null, + Authentication.currentUser + ? m('div.admin-actions', [ + m('span', 'Admin controls:'), + m(m.route.Link, { href: '/admin/pages/' + this.page.id }, 'Edit page'), + ]) + : null, + ]) + ) + }, +} + +module.exports = Page diff --git a/app/pages/page.scss b/app/pages/page.scss new file mode 100644 index 0000000..9fd3eeb --- /dev/null +++ b/app/pages/page.scss @@ -0,0 +1,161 @@ +article.page { + background: white; + padding: 0 0 20px; + + header { + text-align: center; + margin: 20px; + padding: 10px; + background: $secondary-bg; + width: 100%; + max-width: 1920px; + align-self: center; + + h1 { + color: $secondary-fg; + } + } + + .page-banner { + background-size: auto 100%; + background-repeat: no-repeat; + background-position: center; + height: 100px; + width: 100%; + max-width: 1920px; + align-self: center; + flex: 0 0 100px; + } + + .page-cover { + margin: 0 -10px 20px; + } + + .admin-actions { + margin-bottom: 20px; + } + + & > .loading-spinner { + width: 240px; + height: 50px; + position: relative; + } + + aside.sidebar, + aside.news { + h4 { + font-size: 14px; + font-weight: bold; + margin: 0 0 10px; + } + + a { + display: inline-block; + padding-top: 5px; + text-decoration: none; + color: $secondary-bg; + font-size: 14px; + font-weight: bold; + } + } + + .container { + flex-direction: column; + align-items: center; + height: auto; + + &.multi { + align-self: center; + align-items: flex-start; + flex-direction: row; + flex-grow: 2; + width: 100%; + max-width: 1050px; + } + } + + aside.sidebar { + width: 250px; + flex: 0 0 250px; + padding: 0 10px; + margin-bottom: 10px; + + h4 { + padding: 0 5px 5px; + border-bottom: 1px solid $border; + } + + a { + padding: 5px 5px 0px; + display: block; + } + } + + .fr-view { + margin: 0 20px; + padding: 0 20px; + width: calc(100% - 40px); + max-width: 800px; + flex: 2 0 0; + + main { + padding: 0 5px; + } + } +} + +aside.news { + border-top: 1px solid #ccc; + margin-top: 20px; + padding: 10px 10px; + margin: 0 -10px; + width: 100%; + align-self: center; + + newsentry { + margin: 0 0 10px; + } + + &.single { + max-width: 800px; + flex: 2 0 0; + border-top: none; + margin-top: 0; + + & > h4 { + display: none; + } + } +} + +@media screen and (max-device-width: 639px){ + article.page .container { + flex-direction: column !important; + } + + article.page aside.sidebar { + width: calc(100% - 80px); + flex: 0 0 auto; + margin: 0px 30px 30px; + border-bottom: 1px solid $border; + padding: 0 0 5px; + } +} + +@media screen and (max-device-width: 360px){ + article.page { + .container { + flex-direction: column; + } + + aside { + margin: 0px 0px 10px; + } + + .fr-view { + margin: 0; + width: 100%; + padding: 0 5px; + } + } +} diff --git a/app/widgets/common.scss b/app/widgets/common.scss index f737c50..b64eef0 100644 --- a/app/widgets/common.scss +++ b/app/widgets/common.scss @@ -1,25 +1,51 @@ + + fileupload { position: relative; display: flex; align-items: stretch; + flex-direction: column; + justify-content: stretch; .showicon, + .showbordericon, .display { - border: 3px solid $title-fg; - border-style: dashed; flex-grow: 2; } - .showicon { + .showbordericon { + border: 3px solid $title-fg; + border-style: dashed; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHaSURBVHhe7dfPScRAAIXxFSxAPHjUkrx61DaswCJsw5MdCDYkgs6DDIQlu5l/Sd5Ovh88WDJ7yPCdcgAAAAAAAEDvHofBwFPYzzD9xoaew37D/obpt55hAy9h4xjjKDrDik7FiCPKiuZixBFlBakx4oiyoNwYcURZQGmMOKI0VBsjjigNtIoRR5QKrWPEEaVASoyPiWdx5840omRIifEe9nD0bDyd6T9TZ3FESZAa4yrsfvTseDrTf4hSISeGzAURohTKjSEpQYQomUpiSGoQIUqi0hiSE0SIMqMmhuQGEaKcUBtDSoIIUY60iCGlQYQog1YxpCaI7D5KyxhSG0R2G+U67Cts6sJxOTGkRRBJiaJ31x26chv2HTZ14dwY0iqInIuid9a7d2kqSkkMaRlEpqJ0HSMaRymNIa2DyDjKLmJEuuhrWGkMWSKI6J30bruJ0cpSQVCIIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZggCAACAjd2FfXY+3fFinPtG6GUX9a1DEDMEMUMQMwQxcxP21vl0RwAAAAAAAGCnDod/1p4xx4l+w0cAAAAASUVORK5CYII='); background-position: center; background-repeat: no-repeat; background-size: 32px; } + .showicon { + position: absolute; + top: 50%; + left: 50%; + margin-left: -16px; + margin-top: -16px; + width: 32px; + height: 32px; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHaSURBVHhe7dfPScRAAIXxFSxAPHjUkrx61DaswCJsw5MdCDYkgs6DDIQlu5l/Sd5Ovh88WDJ7yPCdcgAAAAAAAEDvHofBwFPYzzD9xoaew37D/obpt55hAy9h4xjjKDrDik7FiCPKiuZixBFlBakx4oiyoNwYcURZQGmMOKI0VBsjjigNtIoRR5QKrWPEEaVASoyPiWdx5840omRIifEe9nD0bDyd6T9TZ3FESZAa4yrsfvTseDrTf4hSISeGzAURohTKjSEpQYQomUpiSGoQIUqi0hiSE0SIMqMmhuQGEaKcUBtDSoIIUY60iCGlQYQog1YxpCaI7D5KyxhSG0R2G+U67Cts6sJxOTGkRRBJiaJ31x26chv2HTZ14dwY0iqInIuid9a7d2kqSkkMaRlEpqJ0HSMaRymNIa2DyDjKLmJEuuhrWGkMWSKI6J30bruJ0cpSQVCIIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZggCAACAjd2FfXY+3fFinPtG6GUX9a1DEDMEMUMQMwQxcxP21vl0RwAAAAAAAGCnDod/1p4xx4l+w0cAAAAASUVORK5CYII='); + background-position: center; + background-repeat: no-repeat; + background-size: 32px; + } + + img { + max-width: 600px; + width: calc(100% - 80px); + align-self: center; + } + .display { - border: none; - background-size: contain; + background-size: cover; + background-repeat: no-repeat; + background-position: center; } .loading-spinner { @@ -45,3 +71,84 @@ fileupload { z-index: 2; } } + +dialogue { + background: white; + display: flex; + flex-direction: column; + text-align: center; + width: calc(100% - 40px); + max-width: 500px; +} + +dialogue h2 { + background: $secondary-dark-bg; + color: $secondary-dark-fg; + font-size: 1.5em; + padding: 10px; +} + +dialogue p { + padding: 10px; +} + +dialogue .buttons { + display: flex; + justify-content: space-around; + padding: 10px; +} + +dialogue button { + border: 1px solid $secondary-dark-bg; + background: transparent; + color: $secondary-dark-bg; + padding: 5px 15px; + min-width: 150px; +} + +dialogue button.alert { + border-color: red; + color: red; +} + +dialogue button.cancel { + border-color: #999; + color: #999; +} + +newsentry { + display: flex; + color: $meta-fg; + font-size: 12px; + + a { + &.cover { + flex-shrink: 0; + margin-right: 10px; + + img { + max-height: 70px; + width: auto; + } + } + + &.nobg { + height: 70px; + width: 124px; + background: #ddd; + } + } + + .entrycontent { + display: flex; + flex-direction: column; + flex: 2 1 auto; + padding: 0 5px 5px; + + h3 { + margin-bottom: 10px !important; + font-size: 1.3em; + font-weight: bold; + } + } +} diff --git a/app/widgets/dialogue.js b/app/widgets/dialogue.js new file mode 100644 index 0000000..fcbba92 --- /dev/null +++ b/app/widgets/dialogue.js @@ -0,0 +1,19 @@ +const m = require('mithril') + +const Dialogue = { + view: function(vnode) { + return m('div.floating-container', { + hidden: vnode.attrs.hidden, + }, m('dialogue', [ + m('h2', vnode.attrs.title), + m('p', vnode.attrs.message), + m('div.buttons', [ + m('button', { class: vnode.attrs.yesclass || '', onclick: vnode.attrs.onyes }, vnode.attrs.yes), + m('button', { class: vnode.attrs.noclass || '', onclick: vnode.attrs.onno }, vnode.attrs.no), + ]) + ]) + ) + }, +} + +module.exports = Dialogue diff --git a/app/widgets/fileupload.js b/app/widgets/fileupload.js index 192fe95..ad47e0b 100644 --- a/app/widgets/fileupload.js +++ b/app/widgets/fileupload.js @@ -4,15 +4,17 @@ const { uploadMedia } = require('../api/media') const FileUpload = { uploadFile(vnode, event) { if (!event.target.files[0]) return + vnode.state.updateError(vnode, '') vnode.state.loading = true uploadMedia(event.target.files[0]) .then(function(res) { - vnode.state.media = res - console.log(vnode.state.media) + if (vnode.attrs.onupload) { + vnode.attrs.onupload(res) + } }) .catch(function(err) { - console.log(err) + vnode.state.updateError(vnode, err.message) }) .then(function() { vnode.state.loading = false @@ -20,29 +22,43 @@ const FileUpload = { }) }, + updateError: function(vnode, error) { + if (vnode.attrs.onerror) { + vnode.attrs.onerror(error) + } else { + vnode.state.error = error + } + }, + oninit: function(vnode) { vnode.state.loading = false - vnode.state.media = null vnode.state.error = '' }, view: function(vnode) { - let media = vnode.state.media + let media = vnode.attrs.media - return m('fileupload', [ - (media ? - m('a.display', { - href: media.large_url, - style: { - 'background-image': 'url(' + media.medium_url + ')', - } - }) : - m('div.showicon') + return m('fileupload', { + class: vnode.attrs.class || null, + }, [ + m('div.error', { + hidden: !vnode.state.error, + }, vnode.state.error), + (media + ? vnode.attrs.useimg + ? [ m('img', { src: media.large_url }), m('div.showicon')] + : m('a.display.inside', { + href: media.large_url, + style: { + 'background-image': 'url(' + media.medium_url + ')', + }, + }, m('div.showicon')) + : m('div.inside.showbordericon') ), m('input', { accept: 'image/*', type: 'file', - onchange: FileUpload.uploadFile.bind(this, vnode), + onchange: this.uploadFile.bind(this, vnode), }), (vnode.state.loading ? m('div.loading-spinner') : null), ]) diff --git a/app/widgets/newsentry.js b/app/widgets/newsentry.js new file mode 100644 index 0000000..2c1d4a3 --- /dev/null +++ b/app/widgets/newsentry.js @@ -0,0 +1,22 @@ +const m = require('mithril') + +const Newsentry = { + view: function(vnode) { + return m('newsentry', [ + vnode.attrs.media + ? m('a.cover', { + href: vnode.attrs.media.large_url, + }, m('img', { src: vnode.attrs.media.small_url })) + : m('a.cover.nobg'), + m('div.entrycontent', [ + m(m.route.Link, + { href: '/article/' + vnode.attrs.path }, + m('h3', vnode.attrs.name) + ), + m('div.entrymeta', 'Posted ' + vnode.attrs.created_at.replace('T', ' ').split('.')[0]) + ]) + ]) + }, +} + +module.exports = Newsentry diff --git a/config/config.default.json b/config/config.default.json index e3110d3..01fa98e 100644 --- a/config/config.default.json +++ b/config/config.default.json @@ -12,8 +12,10 @@ "password" : "postgres", "database" : "nfpmoe" }, + "connectionslave": null, "migrations": { - } + }, + "acquireConnectionTimeout": 10000 }, "bunyan": { "name": "nfpmoe", @@ -37,6 +39,8 @@ "bcrypt": 5, "fileSize": 524288000, "upload": { + "port": "2111", + "host": "storage01.nfp.is", "name": "nfpmoe-dev", "secret": "nfpmoe-dev" } diff --git a/index.mjs b/index.mjs index f699fbd..aa2962d 100644 --- a/index.mjs +++ b/index.mjs @@ -3,12 +3,17 @@ import log from './api/log' // Run the database script automatically. import setup from './api/setup' -setup().then(() => +setup().catch(async (error) => { + log.error({ code: error.code, message: error.message }, 'Error while preparing database') + log.error('Unable to verify database integrity.') + log.warn('Continuing anyways') + // import('./api/config').then(module => { + // log.error(error, 'Error while preparing database') + // log.error({ config: module.default.get() }, 'config used') + // process.exit(1) + // }) +}).then(() => import('./server') -).catch((error) => { - import('./api/config').then(module => { - log.error(error, 'Error while preparing database') - log.error({ config: module.default.get() }, 'config used') - process.exit(1) - }) +).catch(error => { + log.error(error, 'Unknown error starting server') }) diff --git a/migrations/20190219105500_base.js b/migrations/20190219105500_base.js index 84fb88e..4031cb3 100644 --- a/migrations/20190219105500_base.js +++ b/migrations/20190219105500_base.js @@ -29,8 +29,72 @@ exports.up = function up(knex, Promise) { table.text('medium_image') table.text('large_image') table.integer('size') - table.integer('login_id') + table.integer('staff_id') .references('staff.id') + table.boolean('is_deleted') + .notNullable() + .default(false) + table.timestamps() + }), + knex.schema.createTable('files', function(table) { + table.increments() + table.text('filename') + table.text('filetype') + table.integer('size') + table.integer('staff_id') + .references('staff.id') + table.boolean('is_deleted') + .notNullable() + .default(false) + table.timestamps() + }), + knex.schema.createTable('pages', function(table) { + table.increments() + table.integer('staff_id') + .references('staff.id') + table.integer('parent_id') + .references('pages.id') + table.text('name') + table.text('path') + table.text('description') + table.integer('banner_id') + .references('media.id') + .defaultTo(null) + table.integer('media_id') + .references('media.id') + .defaultTo(null) + table.boolean('is_deleted') + .notNullable() + .default(false) + table.timestamps() + }), + knex.schema.createTable('articles', function(table) { + table.increments() + table.integer('staff_id') + .references('staff.id') + table.integer('parent_id') + .references('pages.id') + table.text('name') + table.text('path') + table.text('description') + table.integer('banner_id') + .references('media.id') + .defaultTo(null) + table.integer('media_id') + .references('media.id') + .defaultTo(null) + table.boolean('is_deleted') + .notNullable() + .default(false) + table.timestamps() + }), + knex.schema.createTable('files', function(table) { + table.increments() + table.integer('file_id') + .references('files.id') + table.text('filename') + table.text('filetype') + table.integer('size') table.integer('staff_id') .references('staff.id') table.boolean('is_deleted') diff --git a/package.json b/package.json index c99560d..60f6616 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,11 @@ "scripts": { "lint": "eslint .", "start": "node --experimental-modules index.mjs", - "build": "sass app/app.scss public/assets/app.css && browserify -d app/index.js -o public/assets/app.js", + "build": "sass -s compressed app/app.scss public/assets/app.css && browserify -p tinyify --bare --no-browser-field -o public/assets/app.js app/index.js", + "build:check": "browserify -t uglifyify --bare --no-browser-field --list app/index.js", "test": "echo \"Error: no test specified\" && exit 1", "watch:api": "nodemon --experimental-modules index.mjs | bunyan", - "watch:app": "watchify -d app/index.js -o public/assets/app.js", + "watch:app": "watchify -g envify -d app/index.js -o public/assets/app.js", "watch:sass": "sass --watch app/app.scss public/assets/app.css", "dev": "run-p watch:api watch:app watch:sass", "prod": "npm run build && npm start" @@ -29,11 +30,13 @@ "homepage": "https://github.com/nfp-projects/nfp_moe", "dependencies": { "@koa/cors": "^2.2.3", - "bookshelf": "^0.14.2", + "bookshelf": "^0.15.1", "bunyan-lite": "^1.0.1", + "dot": "^1.1.2", "format-link-header": "^2.1.0", - "googleapis": "^37.2.0", + "googleapis": "^42.0.0", "http-errors": "^1.7.2", + "json-mask": "^0.3.8", "jsonwebtoken": "^8.4.0", "knex": "^0.16.3", "koa": "^2.7.0", @@ -44,17 +47,18 @@ "lodash": "^4.17.11", "multer": "^1.4.1", "nconf": "^0.10.0", + "parse-torrent": "^7.0.1", "pg": "^7.8.0", - "sharp": "^0.21.3", - "socks5-http-client": "^1.0.4" + "sharp": "^0.21.3" }, "devDependencies": { "browserify": "^16.2.3", "eslint": "^5.14.1", - "mithril": "^2.0.0-rc.4", + "mithril": "^2.0.3", "nodemon": "^1.18.10", "npm-run-all": "^4.1.5", "sass": "^1.17.0", + "tinyify": "^2.5.1", "watchify": "^3.11.0" } } diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..14267e9 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / \ No newline at end of file diff --git a/server.mjs b/server.mjs index bc3ed8f..ae33b19 100644 --- a/server.mjs +++ b/server.mjs @@ -7,6 +7,7 @@ import router from './api/router' import Jwt from './api/jwt' import log from './api/log' import { serve } from './api/serve' +import { mask } from './api/middlewares/mask' import { errorHandler } from './api/error/middleware' import { accessChecks } from './api/access/middleware' import ParserMiddleware from './api/parser/middleware' @@ -14,12 +15,13 @@ import ParserMiddleware from './api/parser/middleware' const app = new Koa() const parser = new ParserMiddleware() +app.use(log.logMiddleware()) app.use(errorHandler()) +app.use(mask()) app.use(bodyParser()) app.use(parser.contextParser()) app.use(accessChecks()) app.use(parser.generateLinks()) -app.use(log.logMiddleware()) app.use(Jwt.jwtMiddleware()) app.use(cors({ exposeHeaders: ['link', 'pagination_total'],