major updates

This commit is contained in:
Jonatan Nilsson 2019-09-13 13:33:10 +00:00
parent cc025b2393
commit ea30963825
53 changed files with 2514 additions and 150 deletions

View file

@ -7,16 +7,12 @@
} }
}, },
"extends": "eslint:recommended", "extends": "eslint:recommended",
"plugins": [
"mocha"
],
"env": { "env": {
"mocha": true, "mocha": true,
"node": true, "node": true,
"es6": true "es6": true
}, },
"rules": { "rules": {
"mocha/no-exclusive-tests": 2,
"require-await": 0, "require-await": 0,
"array-callback-return": 2, "array-callback-return": 2,
"block-scoped-var": 2, "block-scoped-var": 2,
@ -46,7 +42,7 @@
"no-catch-shadow": 2, "no-catch-shadow": 2,
"no-shadow": 2, "no-shadow": 2,
"no-undef-init": 2, "no-undef-init": 2,
"no-undefined": 0, "no-undefined": 2,
"no-use-before-define": 2, "no-use-before-define": 2,
"no-new-require": 2, "no-new-require": 2,
"no-sync": 2, "no-sync": 2,

51
api/article/model.mjs Normal file
View file

@ -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

61
api/article/routes.mjs Normal file
View file

@ -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
}
}

37
api/article/security.mjs Normal file
View file

@ -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(', ')}`)
}
}

View file

@ -6,11 +6,230 @@ import config from './config'
import defaults from './defaults' import defaults from './defaults'
import log from './log' 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 // Check if we're running tests while connected to
// potential production environment. // potential production environment.
@ -122,6 +341,7 @@ shelf.createModel = (attr, opts) => {
!options.query._statements[0].column.indexOf || !options.query._statements[0].column.indexOf ||
options.query._statements[0].column.indexOf('is_deleted') === -1) { options.query._statements[0].column.indexOf('is_deleted') === -1) {
// First override that is_deleted always gets filtered out. // First override that is_deleted always gets filtered out.
options.query.where(`${collection.tableName()}.is_deleted`, false) options.query.where(`${collection.tableName()}.is_deleted`, false)
} }

26
api/file/model.mjs Normal file
View file

@ -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

49
api/file/routes.mjs Normal file
View file

@ -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
}
}

8
api/filter.mjs Normal file
View file

@ -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 []
}

12
api/index/routes.mjs Normal file
View file

@ -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
}
}

View file

@ -37,9 +37,21 @@ const Media = bookshelf.createModel({
large_url() { large_url() {
return `${Media.baseUrl}${this.get('large_image')}` 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) { getSubUrl(input, size) {
if (!input) return input if (!input) return input

View file

@ -13,7 +13,10 @@ export default class Resizer {
let output = this.Media.getSubUrl(input, 'small') let output = this.Media.getSubUrl(input, 'small')
return this.sharp(input) return this.sharp(input)
.resize(300, 300) .resize(360, 360, {
fit: sharp.fit.inside,
withoutEnlargement: true
})
.toFile(output) .toFile(output)
.then(() => output) .then(() => output)
} }
@ -22,7 +25,10 @@ export default class Resizer {
let output = this.Media.getSubUrl(input, 'medium') let output = this.Media.getSubUrl(input, 'medium')
return this.sharp(input) return this.sharp(input)
.resize(700, 700) .resize(700, 700, {
fit: sharp.fit.inside,
withoutEnlargement: true
})
.toFile(output) .toFile(output)
.then(() => output) .then(() => output)
} }

View file

@ -1,6 +1,6 @@
import config from '../config' import config from '../config'
import Media from './model' import Media from './model'
import * as multer from './multer' import * as multer from '../multer'
import Resizer from './resize' import Resizer from './resize'
import { uploadFile } from './upload' import { uploadFile } from './upload'
import Jwt from '../jwt' import Jwt from '../jwt'
@ -40,4 +40,20 @@ export default class MediaRoutes {
staff_id: ctx.state.user.id, 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
}
} }

View file

@ -1,7 +1,7 @@
import http from 'http' import http from 'http'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import Agent from 'socks5-http-client/lib/Agent' import config from '../config'
let stub let stub
@ -31,19 +31,14 @@ export function uploadFile(token, file) {
]) ])
const options = { const options = {
port: 2111, port: config.get('upload:port'),
hostname: 'storage01.nfp.is', hostname: config.get('upload:host'),
method: 'POST', method: 'POST',
path: '/media?token=' + token, path: '/media?token=' + token,
headers: { headers: {
'Content-Type': 'multipart/form-data; boundary=' + boundary, 'Content-Type': 'multipart/form-data; boundary=' + boundary,
'Content-Length': multipartBody.length, 'Content-Length': multipartBody.length,
}, },
agentClass: Agent,
agentOptions: {
socksHost: '127.0.0.1',
socksPort: 5555,
},
} }
const req = http.request(options) const req = http.request(options)
@ -65,7 +60,10 @@ export function uploadFile(token, file) {
try { try {
output = JSON.parse(output) output = JSON.parse(output)
} catch (e) { } catch (e) {
// Do nothing return reject(e)
}
if (output.status) {
return reject(new Error(`Unable to upload! [${output.status}]: ${output.message}`))
} }
resolve(output) resolve(output)
}) })

18
api/middlewares/mask.mjs Normal file
View file

@ -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))
}
}

66
api/page/model.mjs Normal file
View file

@ -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

61
api/page/routes.mjs Normal file
View file

@ -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
}
}

37
api/page/security.mjs Normal file
View file

@ -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(', ')}`)
}
}

View file

@ -4,6 +4,9 @@ import Router from 'koa-router'
import access from './access' import access from './access'
import AuthRoutes from './authentication/routes' import AuthRoutes from './authentication/routes'
import MediaRoutes from './media/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' import { restrict } from './access/middleware'
const router = new Router() const router = new Router()
@ -14,6 +17,28 @@ router.post('/api/login', authentication.login.bind(authentication))
// API Media // API Media
const media = new MediaRoutes() 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.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 export default router

View file

@ -1,6 +1,6 @@
$primary-bg: #01579b; $primary-bg: #01579b;
$primary-fg: white; $primary-fg: white;
$primary-light-bg: #4f83cc; $primary-light-bg: #3D77C7; /*#4f83cc;*/
$primary-light-fg: white; $primary-light-fg: white;
$primary-dark-bg: #002f6c; $primary-dark-bg: #002f6c;
$primary-dark-fg: white; $primary-dark-fg: white;
@ -14,4 +14,5 @@ $secondary-dark-fg: white;
$border: #ccc; $border: #ccc;
$title-fg: #555; $title-fg: #555;
$meta-fg: #999; $meta-fg: #757575; /* #999 */
$meta-light-fg: #999999;

View file

@ -4,43 +4,36 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: $primary-bg; background: $primary-bg;
padding: 20px; padding: 0 20px 20px;
} }
article.editcat { .admin-actions {
text-align: center; background: $primary-bg;
background: white; display: flex;
padding: 0 0 20px; justify-content: center;
min-height: 37px;
header { span {
color: $primary-fg;
padding: 10px; padding: 10px;
background: $secondary-bg; font-size: 14px;
font-weight: bold;
}
h1 { a {
color: $secondary-fg; padding: 10px;
text-decoration: none;
color: $secondary-light-bg;
font-size: 14px;
font-weight: bold;
} }
} }
fileupload { .fr-box,
margin: 0 20px 20px; .fr-toolbar,
min-height: 100px; .fr-box .second-toolbar {
border-radius: 0 !important;
} }
form { @import 'pages';
align-items: center; @import 'articles';
align-self: center;
padding: 20px 40px;
}
h5 {
margin-bottom: 20px;
}
& > .loading-spinner {
width: 240px;
height: 50px;
position: relative;
}
}
@import 'editcat'

109
app/admin/articles.js Normal file
View file

@ -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

87
app/admin/articles.scss Normal file
View file

@ -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;
}

244
app/admin/editarticle.js Normal file
View file

@ -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

View file

@ -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

View file

213
app/admin/editpage.js Normal file
View file

@ -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

46
app/admin/froala.js Normal file
View file

@ -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

114
app/admin/pages.js Normal file
View file

@ -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

66
app/admin/pages.scss Normal file
View file

@ -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;
}

38
app/api/article.js Normal file
View file

@ -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,
})
}

View file

@ -15,6 +15,9 @@ exports.sendRequest = function(options) {
Authentication.clearToken() Authentication.clearToken()
m.route.set('/login', { redirect: m.route.get() }) m.route.set('/login', { redirect: m.route.get() })
} }
if (error.response && error.response.status) {
return Promise.reject(error.response)
}
return Promise.reject(error) return Promise.reject(error)
}) })
} }

View file

@ -8,6 +8,6 @@ exports.uploadMedia = function(file) {
return sendRequest({ return sendRequest({
method: 'POST', method: 'POST',
url: '/api/media', url: '/api/media',
data: formData, body: formData,
}) })
} }

99
app/api/page.js Normal file
View file

@ -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
})
}

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,4 @@
const m = require('mithril') const m = require('mithril')
const jwt = require('jsonwebtoken')
const storageName = 'logintoken' const storageName = 'logintoken'
const loadingListeners = [] const loadingListeners = []
@ -20,7 +19,7 @@ const Authentication = {
updateToken: function(token) { updateToken: function(token) {
if (!token) return Authentication.clearToken() if (!token) return Authentication.clearToken()
localStorage.setItem(storageName, token) localStorage.setItem(storageName, token)
Authentication.currentUser = jwt.decode(token) Authentication.currentUser = JSON.parse(atob(token.split('.')[1]))
}, },
clearToken: function() { clearToken: function() {

View file

@ -1,13 +1,17 @@
const m = require('mithril') const m = require('mithril')
m.route.prefix('') m.route.prefix = ''
const Authentication = require('./authentication') const Authentication = require('./authentication')
const Menu = require('./menu/menu') const Menu = require('./menu/menu')
const Frontpage = require('./frontpage/frontpage') const Frontpage = require('./frontpage/frontpage')
const Login = require('./login/login') const Login = require('./login/login')
const Logout = require('./login/logout') 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 menuRoot = document.getElementById('nav')
const mainRoot = document.getElementById('main') const mainRoot = document.getElementById('main')
@ -16,6 +20,10 @@ m.route(mainRoot, '/', {
'/': Frontpage, '/': Frontpage,
'/login': Login, '/login': Login,
'/logout': Logout, '/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) m.mount(menuRoot, Menu)

View file

@ -16,7 +16,7 @@ const Login = {
'theme': 'dark', 'theme': 'dark',
'onsuccess': Login.onGoogleSuccess, 'onsuccess': Login.onGoogleSuccess,
'onfailure': Login.onGoogleFailure, 'onfailure': Login.onGoogleFailure,
}); })
}, },
onGoogleSuccess: function(googleUser) { onGoogleSuccess: function(googleUser) {
@ -26,15 +26,15 @@ const Login = {
m.request({ m.request({
method: 'POST', method: 'POST',
url: '/api/login', url: '/api/login',
data: { token: googleUser.Zi.access_token }, body: { token: googleUser.Zi.access_token },
}) })
.then(function(result) { .then(function(result) {
Authentication.updateToken(result.token) Authentication.updateToken(result.token)
m.route.set(Login.redirect || '/') m.route.set(Login.redirect || '/')
}) })
.catch(function(error) { .catch(function(error) {
Login.error = 'Error while logging into NFP! ' + error.code + ': ' + error.response.message Login.error = 'Error while logging into NFP! ' + error.status + ': ' + error.message
let auth2 = gapi.auth2.getAuthInstance(); let auth2 = gapi.auth2.getAuthInstance()
return auth2.signOut() return auth2.signOut()
}) })
.then(function () { .then(function () {
@ -44,9 +44,11 @@ const Login = {
}, },
onGoogleFailure: function(error) { onGoogleFailure: function(error) {
Login.error = 'Error while logging into Google: ' + error 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() m.redraw()
Authentication.createGoogleScript() }
}, },
oninit: function(vnode) { oninit: function(vnode) {
@ -75,11 +77,11 @@ const Login = {
Login.error ? m('div.error', Login.error) : null, Login.error ? m('div.error', Login.error) : null,
Login.loading ? m('div.loading-spinner') : null, Login.loading ? m('div.loading-spinner') : null,
m('div#googlesignin', { hidden: Login.loading }, m('div.loading-spinner')), m('div#googlesignin', { hidden: Login.loading }, m('div.loading-spinner')),
]) ]),
]), ]),
]), ]),
] ]
} },
} }
module.exports = Login module.exports = Login

View file

@ -17,6 +17,8 @@ const Logout = {
.then(function() { .then(function() {
Authentication.clearToken() Authentication.clearToken()
m.route.set('/') m.route.set('/')
}, function(err) {
console.log('unable to logout:', err)
}) })
}, },

View file

@ -1,18 +1,36 @@
const m = require('mithril') const m = require('mithril')
const Authentication = require('../authentication') const Authentication = require('../authentication')
const { getAllPages, Tree, getTree } = require('../api/page')
const Menu = { const Menu = {
currentActive: 'home', currentActive: 'home',
error: '',
loading: false,
onbeforeupdate: function() { onbeforeupdate: function() {
let currentPath = m.route.get() let currentPath = m.route.get()
if (currentPath === '/') Menu.currentActive = 'home' if (currentPath === '/') Menu.currentActive = 'home'
else if (currentPath === '/login') Menu.currentActive = 'login' else if (currentPath === '/login') Menu.currentActive = 'login'
else Menu.currentActive = 'none' else Menu.currentActive = currentPath
}, },
oninit: function() { oninit: function(vnode) {
Menu.onbeforeupdate() 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() { view: function() {
@ -21,25 +39,39 @@ const Menu = {
m('h2', 'NFP Moe'), m('h2', 'NFP Moe'),
m('aside', Authentication.currentUser ? [ m('aside', Authentication.currentUser ? [
m('p', 'Welcome ' + Authentication.currentUser.email), m('p', 'Welcome ' + Authentication.currentUser.email),
(Authentication.currentUser.level >= 100 ? (Authentication.currentUser.level >= 100
m('a[href=/admin/addcat]', { oncreate: m.route.link }, 'Create category') ? [
m(m.route.Link, { href: '/admin/pages' }, 'Pages'),
m(m.route.Link, { href: '/admin/articles' }, 'Articles'),
]
: null : 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('nav', [
m('a[href=/]', { m(m.route.Link, {
href: '/',
class: Menu.currentActive === 'home' ? 'active' : '', class: Menu.currentActive === 'home' ? 'active' : '',
oncreate: m.route.link
}, 'Home'), }, 'Home'),
m('a[href=/articles]', { Menu.loading ? m('div.loading-spinner') : Tree.map(function(page) {
class: Menu.currentActive === 'articles' ? 'active' : '', if (page.children.length) {
oncreate: m.route.link return m('div.hassubmenu', [
}, 'Articles'), 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,
] ]
} }
} }

View file

@ -25,7 +25,7 @@
p { p {
font-size: 0.8em; font-size: 0.8em;
color: $meta-fg; color: $meta-light-fg;
padding-bottom: 5px; padding-bottom: 5px;
} }
@ -45,6 +45,12 @@
background: $primary-light-bg; background: $primary-light-bg;
color: $primary-light-fg; color: $primary-light-fg;
.hassubmenu {
flex-grow: 2;
flex-basis: 0;
display: flex;
}
a, a:visited { a, a:visited {
flex-grow: 2; flex-grow: 2;
flex-basis: 0; 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;
}
} }

87
app/pages/page.js Normal file
View file

@ -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

161
app/pages/page.scss Normal file
View file

@ -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;
}
}
}

View file

@ -1,25 +1,51 @@
fileupload { fileupload {
position: relative; position: relative;
display: flex; display: flex;
align-items: stretch; align-items: stretch;
flex-direction: column;
justify-content: stretch;
.showicon, .showicon,
.showbordericon,
.display { .display {
border: 3px solid $title-fg;
border-style: dashed;
flex-grow: 2; 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-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHaSURBVHhe7dfPScRAAIXxFSxAPHjUkrx61DaswCJsw5MdCDYkgs6DDIQlu5l/Sd5Ovh88WDJ7yPCdcgAAAAAAAEDvHofBwFPYzzD9xoaew37D/obpt55hAy9h4xjjKDrDik7FiCPKiuZixBFlBakx4oiyoNwYcURZQGmMOKI0VBsjjigNtIoRR5QKrWPEEaVASoyPiWdx5840omRIifEe9nD0bDyd6T9TZ3FESZAa4yrsfvTseDrTf4hSISeGzAURohTKjSEpQYQomUpiSGoQIUqi0hiSE0SIMqMmhuQGEaKcUBtDSoIIUY60iCGlQYQog1YxpCaI7D5KyxhSG0R2G+U67Cts6sJxOTGkRRBJiaJ31x26chv2HTZ14dwY0iqInIuid9a7d2kqSkkMaRlEpqJ0HSMaRymNIa2DyDjKLmJEuuhrWGkMWSKI6J30bruJ0cpSQVCIIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZggCAACAjd2FfXY+3fFinPtG6GUX9a1DEDMEMUMQMwQxcxP21vl0RwAAAAAAAGCnDod/1p4xx4l+w0cAAAAASUVORK5CYII=');
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: 32px; 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 { .display {
border: none; background-size: cover;
background-size: contain; background-repeat: no-repeat;
background-position: center;
} }
.loading-spinner { .loading-spinner {
@ -45,3 +71,84 @@ fileupload {
z-index: 2; 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;
}
}
}

19
app/widgets/dialogue.js Normal file
View file

@ -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

View file

@ -4,15 +4,17 @@ const { uploadMedia } = require('../api/media')
const FileUpload = { const FileUpload = {
uploadFile(vnode, event) { uploadFile(vnode, event) {
if (!event.target.files[0]) return if (!event.target.files[0]) return
vnode.state.updateError(vnode, '')
vnode.state.loading = true vnode.state.loading = true
uploadMedia(event.target.files[0]) uploadMedia(event.target.files[0])
.then(function(res) { .then(function(res) {
vnode.state.media = res if (vnode.attrs.onupload) {
console.log(vnode.state.media) vnode.attrs.onupload(res)
}
}) })
.catch(function(err) { .catch(function(err) {
console.log(err) vnode.state.updateError(vnode, err.message)
}) })
.then(function() { .then(function() {
vnode.state.loading = false 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) { oninit: function(vnode) {
vnode.state.loading = false vnode.state.loading = false
vnode.state.media = null
vnode.state.error = '' vnode.state.error = ''
}, },
view: function(vnode) { view: function(vnode) {
let media = vnode.state.media let media = vnode.attrs.media
return m('fileupload', [ return m('fileupload', {
(media ? class: vnode.attrs.class || null,
m('a.display', { }, [
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, href: media.large_url,
style: { style: {
'background-image': 'url(' + media.medium_url + ')', 'background-image': 'url(' + media.medium_url + ')',
} },
}) : }, m('div.showicon'))
m('div.showicon') : m('div.inside.showbordericon')
), ),
m('input', { m('input', {
accept: 'image/*', accept: 'image/*',
type: 'file', type: 'file',
onchange: FileUpload.uploadFile.bind(this, vnode), onchange: this.uploadFile.bind(this, vnode),
}), }),
(vnode.state.loading ? m('div.loading-spinner') : null), (vnode.state.loading ? m('div.loading-spinner') : null),
]) ])

22
app/widgets/newsentry.js Normal file
View file

@ -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

View file

@ -12,8 +12,10 @@
"password" : "postgres", "password" : "postgres",
"database" : "nfpmoe" "database" : "nfpmoe"
}, },
"connectionslave": null,
"migrations": { "migrations": {
} },
"acquireConnectionTimeout": 10000
}, },
"bunyan": { "bunyan": {
"name": "nfpmoe", "name": "nfpmoe",
@ -37,6 +39,8 @@
"bcrypt": 5, "bcrypt": 5,
"fileSize": 524288000, "fileSize": 524288000,
"upload": { "upload": {
"port": "2111",
"host": "storage01.nfp.is",
"name": "nfpmoe-dev", "name": "nfpmoe-dev",
"secret": "nfpmoe-dev" "secret": "nfpmoe-dev"
} }

View file

@ -3,12 +3,17 @@ import log from './api/log'
// Run the database script automatically. // Run the database script automatically.
import setup from './api/setup' 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') import('./server')
).catch((error) => { ).catch(error => {
import('./api/config').then(module => { log.error(error, 'Unknown error starting server')
log.error(error, 'Error while preparing database')
log.error({ config: module.default.get() }, 'config used')
process.exit(1)
})
}) })

View file

@ -29,8 +29,72 @@ exports.up = function up(knex, Promise) {
table.text('medium_image') table.text('medium_image')
table.text('large_image') table.text('large_image')
table.integer('size') table.integer('size')
table.integer('login_id') table.integer('staff_id')
.references('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') table.integer('staff_id')
.references('staff.id') .references('staff.id')
table.boolean('is_deleted') table.boolean('is_deleted')

View file

@ -9,10 +9,11 @@
"scripts": { "scripts": {
"lint": "eslint .", "lint": "eslint .",
"start": "node --experimental-modules index.mjs", "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", "test": "echo \"Error: no test specified\" && exit 1",
"watch:api": "nodemon --experimental-modules index.mjs | bunyan", "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", "watch:sass": "sass --watch app/app.scss public/assets/app.css",
"dev": "run-p watch:api watch:app watch:sass", "dev": "run-p watch:api watch:app watch:sass",
"prod": "npm run build && npm start" "prod": "npm run build && npm start"
@ -29,11 +30,13 @@
"homepage": "https://github.com/nfp-projects/nfp_moe", "homepage": "https://github.com/nfp-projects/nfp_moe",
"dependencies": { "dependencies": {
"@koa/cors": "^2.2.3", "@koa/cors": "^2.2.3",
"bookshelf": "^0.14.2", "bookshelf": "^0.15.1",
"bunyan-lite": "^1.0.1", "bunyan-lite": "^1.0.1",
"dot": "^1.1.2",
"format-link-header": "^2.1.0", "format-link-header": "^2.1.0",
"googleapis": "^37.2.0", "googleapis": "^42.0.0",
"http-errors": "^1.7.2", "http-errors": "^1.7.2",
"json-mask": "^0.3.8",
"jsonwebtoken": "^8.4.0", "jsonwebtoken": "^8.4.0",
"knex": "^0.16.3", "knex": "^0.16.3",
"koa": "^2.7.0", "koa": "^2.7.0",
@ -44,17 +47,18 @@
"lodash": "^4.17.11", "lodash": "^4.17.11",
"multer": "^1.4.1", "multer": "^1.4.1",
"nconf": "^0.10.0", "nconf": "^0.10.0",
"parse-torrent": "^7.0.1",
"pg": "^7.8.0", "pg": "^7.8.0",
"sharp": "^0.21.3", "sharp": "^0.21.3"
"socks5-http-client": "^1.0.4"
}, },
"devDependencies": { "devDependencies": {
"browserify": "^16.2.3", "browserify": "^16.2.3",
"eslint": "^5.14.1", "eslint": "^5.14.1",
"mithril": "^2.0.0-rc.4", "mithril": "^2.0.3",
"nodemon": "^1.18.10", "nodemon": "^1.18.10",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"sass": "^1.17.0", "sass": "^1.17.0",
"tinyify": "^2.5.1",
"watchify": "^3.11.0" "watchify": "^3.11.0"
} }
} }

2
public/robots.txt Normal file
View file

@ -0,0 +1,2 @@
User-agent: *
Allow: /

View file

@ -7,6 +7,7 @@ import router from './api/router'
import Jwt from './api/jwt' import Jwt from './api/jwt'
import log from './api/log' import log from './api/log'
import { serve } from './api/serve' import { serve } from './api/serve'
import { mask } from './api/middlewares/mask'
import { errorHandler } from './api/error/middleware' import { errorHandler } from './api/error/middleware'
import { accessChecks } from './api/access/middleware' import { accessChecks } from './api/access/middleware'
import ParserMiddleware from './api/parser/middleware' import ParserMiddleware from './api/parser/middleware'
@ -14,12 +15,13 @@ import ParserMiddleware from './api/parser/middleware'
const app = new Koa() const app = new Koa()
const parser = new ParserMiddleware() const parser = new ParserMiddleware()
app.use(log.logMiddleware())
app.use(errorHandler()) app.use(errorHandler())
app.use(mask())
app.use(bodyParser()) app.use(bodyParser())
app.use(parser.contextParser()) app.use(parser.contextParser())
app.use(accessChecks()) app.use(accessChecks())
app.use(parser.generateLinks()) app.use(parser.generateLinks())
app.use(log.logMiddleware())
app.use(Jwt.jwtMiddleware()) app.use(Jwt.jwtMiddleware())
app.use(cors({ app.use(cors({
exposeHeaders: ['link', 'pagination_total'], exposeHeaders: ['link', 'pagination_total'],