major updates
This commit is contained in:
parent
cc025b2393
commit
ea30963825
53 changed files with 2514 additions and 150 deletions
|
@ -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
51
api/article/model.mjs
Normal 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
61
api/article/routes.mjs
Normal 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
37
api/article/security.mjs
Normal 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(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
26
api/file/model.mjs
Normal 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
49
api/file/routes.mjs
Normal 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
8
api/filter.mjs
Normal 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
12
api/index/routes.mjs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
18
api/middlewares/mask.mjs
Normal 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
66
api/page/model.mjs
Normal 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
61
api/page/routes.mjs
Normal 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
37
api/page/security.mjs
Normal 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(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
|
||||||
color: $secondary-fg;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fileupload {
|
a {
|
||||||
margin: 0 20px 20px;
|
padding: 10px;
|
||||||
min-height: 100px;
|
text-decoration: none;
|
||||||
}
|
color: $secondary-light-bg;
|
||||||
|
font-size: 14px;
|
||||||
form {
|
font-weight: bold;
|
||||||
align-items: center;
|
|
||||||
align-self: center;
|
|
||||||
padding: 20px 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h5 {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > .loading-spinner {
|
|
||||||
width: 240px;
|
|
||||||
height: 50px;
|
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@import 'editcat'
|
.fr-box,
|
||||||
|
.fr-toolbar,
|
||||||
|
.fr-box .second-toolbar {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@import 'pages';
|
||||||
|
@import 'articles';
|
||||||
|
|
109
app/admin/articles.js
Normal file
109
app/admin/articles.js
Normal 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
87
app/admin/articles.scss
Normal 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
244
app/admin/editarticle.js
Normal 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
|
|
@ -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
|
|
213
app/admin/editpage.js
Normal file
213
app/admin/editpage.js
Normal 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
46
app/admin/froala.js
Normal 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
114
app/admin/pages.js
Normal 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
66
app/admin/pages.scss
Normal 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
38
app/api/article.js
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
99
app/api/page.js
Normal 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
|
||||||
|
})
|
||||||
|
}
|
126
app/app.scss
126
app/app.scss
File diff suppressed because one or more lines are too long
|
@ -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() {
|
||||||
|
|
14
app/index.js
14
app/index.js
|
@ -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)
|
||||||
|
|
|
@ -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') {
|
||||||
m.redraw()
|
console.error(error)
|
||||||
Authentication.createGoogleScript()
|
Login.error = 'Error while logging into Google: ' + error.error
|
||||||
|
m.redraw()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
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
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
87
app/pages/page.js
Normal 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
161
app/pages/page.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
19
app/widgets/dialogue.js
Normal 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
|
|
@ -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', {
|
}, [
|
||||||
href: media.large_url,
|
m('div.error', {
|
||||||
style: {
|
hidden: !vnode.state.error,
|
||||||
'background-image': 'url(' + media.medium_url + ')',
|
}, vnode.state.error),
|
||||||
}
|
(media
|
||||||
}) :
|
? vnode.attrs.useimg
|
||||||
m('div.showicon')
|
? [ 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', {
|
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
22
app/widgets/newsentry.js
Normal 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
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
19
index.mjs
19
index.mjs
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -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')
|
||||||
|
|
18
package.json
18
package.json
|
@ -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
2
public/robots.txt
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
|
@ -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'],
|
||||||
|
|
Loading…
Reference in a new issue