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",
|
||||
"plugins": [
|
||||
"mocha"
|
||||
],
|
||||
"env": {
|
||||
"mocha": true,
|
||||
"node": true,
|
||||
"es6": true
|
||||
},
|
||||
"rules": {
|
||||
"mocha/no-exclusive-tests": 2,
|
||||
"require-await": 0,
|
||||
"array-callback-return": 2,
|
||||
"block-scoped-var": 2,
|
||||
|
@ -46,7 +42,7 @@
|
|||
"no-catch-shadow": 2,
|
||||
"no-shadow": 2,
|
||||
"no-undef-init": 2,
|
||||
"no-undefined": 0,
|
||||
"no-undefined": 2,
|
||||
"no-use-before-define": 2,
|
||||
"no-new-require": 2,
|
||||
"no-sync": 2,
|
||||
|
|
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 log from './log'
|
||||
|
||||
let host = config.get('knex:connection')
|
||||
let connections = [config.get('knex:connection')]
|
||||
|
||||
log.info(host, 'Connecting to DB')
|
||||
if (config.get('knex:connectionslave')) {
|
||||
connections.push(config.get('knex:connectionslave'))
|
||||
}
|
||||
|
||||
const client = knex(config.get('knex'))
|
||||
let isRecovering = false
|
||||
let isUrgent = false
|
||||
let currentIndex = 0
|
||||
let nextIndex = currentIndex + 1
|
||||
let client
|
||||
let secondaryClient
|
||||
|
||||
/**
|
||||
* Semi-gracefully shift the current active client connection from the
|
||||
* current connected client and switch to the selected index server.
|
||||
*/
|
||||
async function shiftConnection(index) {
|
||||
// Update our variables
|
||||
isUrgent = false
|
||||
currentIndex = index
|
||||
|
||||
log.warn('DB: Destroying current pool')
|
||||
await client.destroy()
|
||||
|
||||
// Update connection settings to the new server and re-initialize the pool.
|
||||
log.warn(connections[currentIndex], 'DB: Connecting to next server')
|
||||
client.client.connectionSettings = connections[currentIndex]
|
||||
client.initialize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a graceful server migration. Creates a secondary database connection
|
||||
* and checks other available servers we have if they're up and can be used.
|
||||
*/
|
||||
async function gracefulServerMigrate() {
|
||||
// Check if we're already recovering and exit then.
|
||||
if (isRecovering) return
|
||||
|
||||
// Urgent means we don't have ANY active database connectiong and need one quickly.
|
||||
if (isUrgent) {
|
||||
log.error(connections[currentIndex], `DB: Server connected to is offline.`)
|
||||
} else {
|
||||
log.warn(connections[currentIndex], `DB: Successfully connected to a server but its status was recovering (slave).`)
|
||||
}
|
||||
log.warn('DB: Attempting to gracefully connecting to different server')
|
||||
isRecovering = true
|
||||
|
||||
// Load up next server into a new knex connection and start connecting.
|
||||
if (nextIndex === connections.length) {
|
||||
nextIndex = 0
|
||||
}
|
||||
secondaryClient = knex(getConfig(nextIndex, false))
|
||||
|
||||
// Keep on trying :)
|
||||
while (true) {
|
||||
// Make multiple attempts when we're connecting to downed or timed out databases.
|
||||
let attempts = 0
|
||||
|
||||
while (attempts++ < 5) {
|
||||
try {
|
||||
log.warn(connections[nextIndex], `DB: Gracefully attempting to connect to server (attempt ${attempts}/5).`)
|
||||
|
||||
// Connect to the database (this creates a new pool connection) and check if it's in recovery mode
|
||||
let data = await secondaryClient.raw('select pg_is_in_recovery()')
|
||||
|
||||
// If we reach here, we got data which means the database is up and running.
|
||||
// As such, there's no need to make more attempts to same server
|
||||
attempts = 6
|
||||
|
||||
// Check if it's master or if we are desperate
|
||||
if (!data.rows[0].pg_is_in_recovery || isUrgent) {
|
||||
// Found a viable server to connect to. Shift our active client to it.
|
||||
log.info(connections[nextIndex], 'DB: Found available server, connecting to it')
|
||||
await shiftConnection(nextIndex)
|
||||
|
||||
// Check if we're connected to master or just a slave.
|
||||
if (!data.rows[0].pg_is_in_recovery) {
|
||||
// We found a master, stop recovering
|
||||
log.info(connections[nextIndex], 'DB: Connection established with master.')
|
||||
isRecovering = false
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// We only care to log weird errors like postgresql errors or such.
|
||||
if (err.code !== 'ECONNREFUSED' && err.code !== 'ETIMEDOUT') {
|
||||
log.error({ code: err.code, message: err.message }, `DB: Unknown error while gracefully connecting to ${connections[nextIndex].host}`)
|
||||
}
|
||||
|
||||
// Make a next attempt after 10 seconds
|
||||
await new Promise(res => setTimeout(res, 10000))
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we found a master and break if we did.
|
||||
if (isRecovering === false) break
|
||||
|
||||
// Didn't find a master :( wait 60 seconds before running another attempt
|
||||
log.warn(connections[nextIndex], 'DB: Connected server was deemeed unable to fit master role')
|
||||
log.warn('DB: waiting 60 seconds before attempting next server')
|
||||
|
||||
await new Promise(res => setTimeout(res, 60000))
|
||||
|
||||
// Move to next server
|
||||
nextIndex++
|
||||
if (nextIndex === connections.length) {
|
||||
nextIndex = 0
|
||||
}
|
||||
|
||||
// Time to destroy our active pool on our current server and update
|
||||
// the connection settings to the next server and re-initialise.
|
||||
await secondaryClient.destroy()
|
||||
secondaryClient.client.connectionSettings = connections[nextIndex]
|
||||
secondaryClient.initialize()
|
||||
}
|
||||
|
||||
// We got here means we have stopped recovery process.
|
||||
// Shut down the secondary knex client and destroy it and
|
||||
// remove reference to it so GC can collect it eventually, hopefully.
|
||||
await secondaryClient.destroy()
|
||||
nextIndex = currentIndex + 1
|
||||
secondaryClient = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler after our pool is created and we are creating a connection.
|
||||
* Here we check if the database is in recovery mode (a.k.a. slave) and if so
|
||||
* start the graceful migration to migrate back to master once it's up and running.
|
||||
*/
|
||||
function afterCreate(conn, done) {
|
||||
conn.query('select pg_is_in_recovery()', (e, res) => {
|
||||
if (e) return done(e, conn)
|
||||
if (res.rows[0].pg_is_in_recovery) gracefulServerMigrate().then()
|
||||
done(null, conn)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler for when the pool gets destroyed. Here we check
|
||||
* if the connection has been marked with _ending = true.
|
||||
* There are some checks available we can use to check if current
|
||||
* connection was abrubtly disconnected. Among those from my testing
|
||||
* are as follows:
|
||||
*
|
||||
* conn.__knex__disposed = 'Connection ended unexpectedly'
|
||||
* conn.connection._ending = true
|
||||
*
|
||||
* I went with connection._ending one as I feel that one's the safest.
|
||||
*
|
||||
*/
|
||||
function beforeDestroy(conn) {
|
||||
if (conn.connection._ending) {
|
||||
checkActiveConnection()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a valid confic for knex based on specific connection index.
|
||||
* Note that we don't wanna hook into afterCreate or beforeDestroy
|
||||
* in our secondary knex connection doing the recovery checking.
|
||||
*/
|
||||
function getConfig(index = 0, addEvents = true) {
|
||||
return {
|
||||
'client': 'pg',
|
||||
'connection': connections[index],
|
||||
'migrations': {
|
||||
},
|
||||
pool: {
|
||||
afterCreate: addEvents && afterCreate || null,
|
||||
min: 2,
|
||||
max: 10,
|
||||
beforeDestroy: addEvents && beforeDestroy || null,
|
||||
},
|
||||
acquireConnectionTimeout: 10000,
|
||||
}
|
||||
}
|
||||
|
||||
client = knex(getConfig(currentIndex))
|
||||
|
||||
/**
|
||||
* Make sure no update or delete queries are run while we're recovering.
|
||||
* This allows knex to connect to a slave and only process select queries.
|
||||
*
|
||||
* Note: Probably does not support complicated select queries that cause
|
||||
* updates on trigger or other such things.
|
||||
*/
|
||||
client.on('query', data => {
|
||||
if (isRecovering && data.method !== 'select') {
|
||||
throw new Error('Database is in read-only mode')
|
||||
}
|
||||
})
|
||||
|
||||
function checkActiveConnection(attempt = 1) {
|
||||
if (attempt > 5) {
|
||||
isUrgent = true
|
||||
return gracefulServerMigrate().then()
|
||||
}
|
||||
// log.info(`DB: (Attempt ${attempt}/5) Checking connection is active.`)
|
||||
client.raw('select 1').catch(err => {
|
||||
if (err.code === 'ECONNREFUSED') { // err.code === 'ETIMEDOUT'
|
||||
isUrgent = true
|
||||
return gracefulServerMigrate().then()
|
||||
}
|
||||
if (err) {
|
||||
let wait = 3000 // err.code like '57P03' and such.
|
||||
if (err.code === 'ETIMEDOUT') {
|
||||
wait = 10000
|
||||
}
|
||||
|
||||
log.error({ code: err.code, message: err.message }, `DB: (Attempt ${attempt}/5) Error while checking connection status`)
|
||||
if (attempt < 5) {
|
||||
log.warn(`DB: (Attempt ${attempt}/5) Attempting again in ${wait / 1000} seconds.`)
|
||||
setTimeout(() => checkActiveConnection(attempt + 1), wait)
|
||||
} else {
|
||||
checkActiveConnection(attempt + 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Only way to check startup connection errors
|
||||
log.info(getConfig(currentIndex).connection, 'DB: Connecting to server')
|
||||
setTimeout(() => checkActiveConnection(), 100)
|
||||
|
||||
// Check if we're running tests while connected to
|
||||
// potential production environment.
|
||||
|
@ -122,6 +341,7 @@ shelf.createModel = (attr, opts) => {
|
|||
!options.query._statements[0].column.indexOf ||
|
||||
options.query._statements[0].column.indexOf('is_deleted') === -1) {
|
||||
// First override that is_deleted always gets filtered out.
|
||||
|
||||
options.query.where(`${collection.tableName()}.is_deleted`, false)
|
||||
}
|
||||
|
||||
|
|
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() {
|
||||
return `${Media.baseUrl}${this.get('large_image')}`
|
||||
},
|
||||
|
||||
link() {
|
||||
return `${Media.baseUrl}${this.get('large_image')}`
|
||||
},
|
||||
|
||||
url() {
|
||||
return `${Media.baseUrl}${this.get('large_image')}`
|
||||
},
|
||||
|
||||
thumb() {
|
||||
return `${Media.baseUrl}${this.get('small_image')}`
|
||||
},
|
||||
},
|
||||
}, {
|
||||
baseUrl: 'https://cdn-nfp.global.ssl.fastly.net',
|
||||
baseUrl: 'http://192.168.42.14',
|
||||
|
||||
getSubUrl(input, size) {
|
||||
if (!input) return input
|
||||
|
|
|
@ -13,7 +13,10 @@ export default class Resizer {
|
|||
let output = this.Media.getSubUrl(input, 'small')
|
||||
|
||||
return this.sharp(input)
|
||||
.resize(300, 300)
|
||||
.resize(360, 360, {
|
||||
fit: sharp.fit.inside,
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.toFile(output)
|
||||
.then(() => output)
|
||||
}
|
||||
|
@ -22,7 +25,10 @@ export default class Resizer {
|
|||
let output = this.Media.getSubUrl(input, 'medium')
|
||||
|
||||
return this.sharp(input)
|
||||
.resize(700, 700)
|
||||
.resize(700, 700, {
|
||||
fit: sharp.fit.inside,
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.toFile(output)
|
||||
.then(() => output)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import config from '../config'
|
||||
import Media from './model'
|
||||
import * as multer from './multer'
|
||||
import * as multer from '../multer'
|
||||
import Resizer from './resize'
|
||||
import { uploadFile } from './upload'
|
||||
import Jwt from '../jwt'
|
||||
|
@ -40,4 +40,20 @@ export default class MediaRoutes {
|
|||
staff_id: ctx.state.user.id,
|
||||
})
|
||||
}
|
||||
|
||||
async getAllMedia(ctx) {
|
||||
ctx.body = await this.Media.getAll(ctx)
|
||||
}
|
||||
|
||||
async removeMedia(ctx) {
|
||||
let media = await this.Media.getSingle(ctx.params.id)
|
||||
|
||||
media.set({
|
||||
is_deleted: true,
|
||||
})
|
||||
|
||||
await media.save()
|
||||
|
||||
ctx.status = 200
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import http from 'http'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import Agent from 'socks5-http-client/lib/Agent'
|
||||
import config from '../config'
|
||||
|
||||
let stub
|
||||
|
||||
|
@ -31,19 +31,14 @@ export function uploadFile(token, file) {
|
|||
])
|
||||
|
||||
const options = {
|
||||
port: 2111,
|
||||
hostname: 'storage01.nfp.is',
|
||||
port: config.get('upload:port'),
|
||||
hostname: config.get('upload:host'),
|
||||
method: 'POST',
|
||||
path: '/media?token=' + token,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data; boundary=' + boundary,
|
||||
'Content-Length': multipartBody.length,
|
||||
},
|
||||
agentClass: Agent,
|
||||
agentOptions: {
|
||||
socksHost: '127.0.0.1',
|
||||
socksPort: 5555,
|
||||
},
|
||||
}
|
||||
|
||||
const req = http.request(options)
|
||||
|
@ -65,7 +60,10 @@ export function uploadFile(token, file) {
|
|||
try {
|
||||
output = JSON.parse(output)
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
return reject(e)
|
||||
}
|
||||
if (output.status) {
|
||||
return reject(new Error(`Unable to upload! [${output.status}]: ${output.message}`))
|
||||
}
|
||||
resolve(output)
|
||||
})
|
||||
|
|
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 AuthRoutes from './authentication/routes'
|
||||
import MediaRoutes from './media/routes'
|
||||
import FileRoutes from './file/routes'
|
||||
import PageRoutes from './page/routes'
|
||||
import ArticleRoutes from './article/routes'
|
||||
import { restrict } from './access/middleware'
|
||||
|
||||
const router = new Router()
|
||||
|
@ -14,6 +17,28 @@ router.post('/api/login', authentication.login.bind(authentication))
|
|||
|
||||
// API Media
|
||||
const media = new MediaRoutes()
|
||||
router.get('/api/media', restrict(access.Manager), media.getAllMedia.bind(media))
|
||||
router.post('/api/media', restrict(access.Manager), media.upload.bind(media))
|
||||
router.del('/api/media/:id', restrict(access.Manager), media.removeMedia.bind(media))
|
||||
|
||||
// API File
|
||||
const file = new FileRoutes()
|
||||
router.get('/api/file', restrict(access.Manager), file.getAllFiles.bind(file))
|
||||
router.post('/api/articles/:articleId/file', restrict(access.Manager), file.upload.bind(file))
|
||||
router.del('/api/file/:id', restrict(access.Manager), file.removeFile.bind(file))
|
||||
|
||||
const page = new PageRoutes()
|
||||
router.get('/api/pages', page.getAllPages.bind(page))
|
||||
router.get('/api/pages/:id', page.getSinglePage.bind(page))
|
||||
router.post('/api/pages', restrict(access.Manager), page.createPage.bind(page))
|
||||
router.put('/api/pages/:id', restrict(access.Manager), page.updatePage.bind(page))
|
||||
router.del('/api/pages/:id', restrict(access.Manager), page.removePage.bind(page))
|
||||
|
||||
const article = new ArticleRoutes()
|
||||
router.get('/api/articles', article.getAllArticles.bind(article))
|
||||
router.get('/api/articles/:id', article.getSingleArticle.bind(article))
|
||||
router.post('/api/articles', restrict(access.Manager), article.createArticle.bind(article))
|
||||
router.put('/api/articles/:id', restrict(access.Manager), article.updateArticle.bind(article))
|
||||
router.del('/api/articles/:id', restrict(access.Manager), article.removeArticle.bind(article))
|
||||
|
||||
export default router
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
$primary-bg: #01579b;
|
||||
$primary-fg: white;
|
||||
$primary-light-bg: #4f83cc;
|
||||
$primary-light-bg: #3D77C7; /*#4f83cc;*/
|
||||
$primary-light-fg: white;
|
||||
$primary-dark-bg: #002f6c;
|
||||
$primary-dark-fg: white;
|
||||
|
@ -14,4 +14,5 @@ $secondary-dark-fg: white;
|
|||
|
||||
$border: #ccc;
|
||||
$title-fg: #555;
|
||||
$meta-fg: #999;
|
||||
$meta-fg: #757575; /* #999 */
|
||||
$meta-light-fg: #999999;
|
||||
|
|
|
@ -4,43 +4,36 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
background: $primary-bg;
|
||||
padding: 20px;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
article.editcat {
|
||||
text-align: center;
|
||||
background: white;
|
||||
padding: 0 0 20px;
|
||||
.admin-actions {
|
||||
background: $primary-bg;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 37px;
|
||||
|
||||
header {
|
||||
span {
|
||||
color: $primary-fg;
|
||||
padding: 10px;
|
||||
background: $secondary-bg;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: $secondary-fg;
|
||||
a {
|
||||
padding: 10px;
|
||||
text-decoration: none;
|
||||
color: $secondary-light-bg;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
fileupload {
|
||||
margin: 0 20px 20px;
|
||||
min-height: 100px;
|
||||
.fr-box,
|
||||
.fr-toolbar,
|
||||
.fr-box .second-toolbar {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
form {
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
padding: 20px 40px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
& > .loading-spinner {
|
||||
width: 240px;
|
||||
height: 50px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@import 'editcat'
|
||||
@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()
|
||||
m.route.set('/login', { redirect: m.route.get() })
|
||||
}
|
||||
if (error.response && error.response.status) {
|
||||
return Promise.reject(error.response)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -8,6 +8,6 @@ exports.uploadMedia = function(file) {
|
|||
return sendRequest({
|
||||
method: 'POST',
|
||||
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 jwt = require('jsonwebtoken')
|
||||
|
||||
const storageName = 'logintoken'
|
||||
const loadingListeners = []
|
||||
|
@ -20,7 +19,7 @@ const Authentication = {
|
|||
updateToken: function(token) {
|
||||
if (!token) return Authentication.clearToken()
|
||||
localStorage.setItem(storageName, token)
|
||||
Authentication.currentUser = jwt.decode(token)
|
||||
Authentication.currentUser = JSON.parse(atob(token.split('.')[1]))
|
||||
},
|
||||
|
||||
clearToken: function() {
|
||||
|
|
14
app/index.js
14
app/index.js
|
@ -1,13 +1,17 @@
|
|||
const m = require('mithril')
|
||||
|
||||
m.route.prefix('')
|
||||
m.route.prefix = ''
|
||||
|
||||
const Authentication = require('./authentication')
|
||||
const Menu = require('./menu/menu')
|
||||
const Frontpage = require('./frontpage/frontpage')
|
||||
const Login = require('./login/login')
|
||||
const Logout = require('./login/logout')
|
||||
const EditCategory = require('./admin/editcat')
|
||||
const EditPage = require('./admin/editpage')
|
||||
const Page = require('./pages/page')
|
||||
const AdminPages = require('./admin/pages')
|
||||
const AdminArticles = require('./admin/articles')
|
||||
const EditArticle = require('./admin/editarticle')
|
||||
|
||||
const menuRoot = document.getElementById('nav')
|
||||
const mainRoot = document.getElementById('main')
|
||||
|
@ -16,6 +20,10 @@ m.route(mainRoot, '/', {
|
|||
'/': Frontpage,
|
||||
'/login': Login,
|
||||
'/logout': Logout,
|
||||
'/admin/addcat': EditCategory,
|
||||
'/page/:key': Page,
|
||||
'/admin/pages': AdminPages,
|
||||
'/admin/pages/:key': EditPage,
|
||||
'/admin/articles': AdminArticles,
|
||||
'/admin/articles/:key': EditArticle,
|
||||
})
|
||||
m.mount(menuRoot, Menu)
|
||||
|
|
|
@ -16,7 +16,7 @@ const Login = {
|
|||
'theme': 'dark',
|
||||
'onsuccess': Login.onGoogleSuccess,
|
||||
'onfailure': Login.onGoogleFailure,
|
||||
});
|
||||
})
|
||||
},
|
||||
|
||||
onGoogleSuccess: function(googleUser) {
|
||||
|
@ -26,15 +26,15 @@ const Login = {
|
|||
m.request({
|
||||
method: 'POST',
|
||||
url: '/api/login',
|
||||
data: { token: googleUser.Zi.access_token },
|
||||
body: { token: googleUser.Zi.access_token },
|
||||
})
|
||||
.then(function(result) {
|
||||
Authentication.updateToken(result.token)
|
||||
m.route.set(Login.redirect || '/')
|
||||
})
|
||||
.catch(function(error) {
|
||||
Login.error = 'Error while logging into NFP! ' + error.code + ': ' + error.response.message
|
||||
let auth2 = gapi.auth2.getAuthInstance();
|
||||
Login.error = 'Error while logging into NFP! ' + error.status + ': ' + error.message
|
||||
let auth2 = gapi.auth2.getAuthInstance()
|
||||
return auth2.signOut()
|
||||
})
|
||||
.then(function () {
|
||||
|
@ -44,9 +44,11 @@ const Login = {
|
|||
},
|
||||
|
||||
onGoogleFailure: function(error) {
|
||||
Login.error = 'Error while logging into Google: ' + error
|
||||
if (error.error !== 'popup_closed_by_user' && error.error !== 'popup_blocked_by_browser') {
|
||||
console.error(error)
|
||||
Login.error = 'Error while logging into Google: ' + error.error
|
||||
m.redraw()
|
||||
Authentication.createGoogleScript()
|
||||
}
|
||||
},
|
||||
|
||||
oninit: function(vnode) {
|
||||
|
@ -75,11 +77,11 @@ const Login = {
|
|||
Login.error ? m('div.error', Login.error) : null,
|
||||
Login.loading ? m('div.loading-spinner') : null,
|
||||
m('div#googlesignin', { hidden: Login.loading }, m('div.loading-spinner')),
|
||||
])
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = Login
|
||||
|
|
|
@ -17,6 +17,8 @@ const Logout = {
|
|||
.then(function() {
|
||||
Authentication.clearToken()
|
||||
m.route.set('/')
|
||||
}, function(err) {
|
||||
console.log('unable to logout:', err)
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
@ -1,18 +1,36 @@
|
|||
const m = require('mithril')
|
||||
const Authentication = require('../authentication')
|
||||
const { getAllPages, Tree, getTree } = require('../api/page')
|
||||
|
||||
const Menu = {
|
||||
currentActive: 'home',
|
||||
error: '',
|
||||
loading: false,
|
||||
|
||||
onbeforeupdate: function() {
|
||||
let currentPath = m.route.get()
|
||||
if (currentPath === '/') Menu.currentActive = 'home'
|
||||
else if (currentPath === '/login') Menu.currentActive = 'login'
|
||||
else Menu.currentActive = 'none'
|
||||
else Menu.currentActive = currentPath
|
||||
},
|
||||
|
||||
oninit: function() {
|
||||
oninit: function(vnode) {
|
||||
Menu.onbeforeupdate()
|
||||
|
||||
Menu.loading = true
|
||||
|
||||
getTree()
|
||||
.then(function(results) {
|
||||
Tree.splice(0, Tree.Length)
|
||||
Tree.push.apply(Tree, results)
|
||||
})
|
||||
.catch(function(err) {
|
||||
Menu.error = err.message
|
||||
})
|
||||
.then(function() {
|
||||
Menu.loading = false
|
||||
m.redraw()
|
||||
})
|
||||
},
|
||||
|
||||
view: function() {
|
||||
|
@ -21,25 +39,39 @@ const Menu = {
|
|||
m('h2', 'NFP Moe'),
|
||||
m('aside', Authentication.currentUser ? [
|
||||
m('p', 'Welcome ' + Authentication.currentUser.email),
|
||||
(Authentication.currentUser.level >= 100 ?
|
||||
m('a[href=/admin/addcat]', { oncreate: m.route.link }, 'Create category')
|
||||
(Authentication.currentUser.level >= 100
|
||||
? [
|
||||
m(m.route.Link, { href: '/admin/pages' }, 'Pages'),
|
||||
m(m.route.Link, { href: '/admin/articles' }, 'Articles'),
|
||||
]
|
||||
: null
|
||||
),
|
||||
m('a[href=/logout]', { oncreate: m.route.link }, 'Logout')
|
||||
m(m.route.Link, { href: '/logout' }, 'Logout')
|
||||
] : [
|
||||
m('a[href=/login]', { oncreate: m.route.link }, 'Login')
|
||||
m(m.route.Link, { href: '/login' }, 'Login')
|
||||
])
|
||||
]),
|
||||
m('nav', [
|
||||
m('a[href=/]', {
|
||||
m(m.route.Link, {
|
||||
href: '/',
|
||||
class: Menu.currentActive === 'home' ? 'active' : '',
|
||||
oncreate: m.route.link
|
||||
}, 'Home'),
|
||||
m('a[href=/articles]', {
|
||||
class: Menu.currentActive === 'articles' ? 'active' : '',
|
||||
oncreate: m.route.link
|
||||
}, 'Articles'),
|
||||
Menu.loading ? m('div.loading-spinner') : Tree.map(function(page) {
|
||||
if (page.children.length) {
|
||||
return m('div.hassubmenu', [
|
||||
m(m.route.Link, {
|
||||
href: '/page/' + page.path,
|
||||
class: Menu.currentActive === ('/page/' + page.path) ? 'active' : '',
|
||||
}, page.name)
|
||||
])
|
||||
}
|
||||
return m(m.route.Link, {
|
||||
href: '/page/' + page.path,
|
||||
class: Menu.currentActive === ('/page/' + page.path) ? 'active' : '',
|
||||
}, page.name)
|
||||
}),
|
||||
]),
|
||||
Menu.error ? m('div.menuerror', Menu.error) : null,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
p {
|
||||
font-size: 0.8em;
|
||||
color: $meta-fg;
|
||||
color: $meta-light-fg;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
|
@ -45,6 +45,12 @@
|
|||
background: $primary-light-bg;
|
||||
color: $primary-light-fg;
|
||||
|
||||
.hassubmenu {
|
||||
flex-grow: 2;
|
||||
flex-basis: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
a, a:visited {
|
||||
flex-grow: 2;
|
||||
flex-basis: 0;
|
||||
|
@ -62,4 +68,18 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
position: relative;
|
||||
flex-grow: 2;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.menuerror {
|
||||
background: $primary-bg;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
color: #FFC7C7;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
|
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 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
|
||||
.showicon,
|
||||
.showbordericon,
|
||||
.display {
|
||||
border: 3px solid $title-fg;
|
||||
border-style: dashed;
|
||||
flex-grow: 2;
|
||||
}
|
||||
|
||||
.showicon {
|
||||
.showbordericon {
|
||||
border: 3px solid $title-fg;
|
||||
border-style: dashed;
|
||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHaSURBVHhe7dfPScRAAIXxFSxAPHjUkrx61DaswCJsw5MdCDYkgs6DDIQlu5l/Sd5Ovh88WDJ7yPCdcgAAAAAAAEDvHofBwFPYzzD9xoaew37D/obpt55hAy9h4xjjKDrDik7FiCPKiuZixBFlBakx4oiyoNwYcURZQGmMOKI0VBsjjigNtIoRR5QKrWPEEaVASoyPiWdx5840omRIifEe9nD0bDyd6T9TZ3FESZAa4yrsfvTseDrTf4hSISeGzAURohTKjSEpQYQomUpiSGoQIUqi0hiSE0SIMqMmhuQGEaKcUBtDSoIIUY60iCGlQYQog1YxpCaI7D5KyxhSG0R2G+U67Cts6sJxOTGkRRBJiaJ31x26chv2HTZ14dwY0iqInIuid9a7d2kqSkkMaRlEpqJ0HSMaRymNIa2DyDjKLmJEuuhrWGkMWSKI6J30bruJ0cpSQVCIIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZggCAACAjd2FfXY+3fFinPtG6GUX9a1DEDMEMUMQMwQxcxP21vl0RwAAAAAAAGCnDod/1p4xx4l+w0cAAAAASUVORK5CYII=');
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 32px;
|
||||
}
|
||||
|
||||
.showicon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-left: -16px;
|
||||
margin-top: -16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHaSURBVHhe7dfPScRAAIXxFSxAPHjUkrx61DaswCJsw5MdCDYkgs6DDIQlu5l/Sd5Ovh88WDJ7yPCdcgAAAAAAAEDvHofBwFPYzzD9xoaew37D/obpt55hAy9h4xjjKDrDik7FiCPKiuZixBFlBakx4oiyoNwYcURZQGmMOKI0VBsjjigNtIoRR5QKrWPEEaVASoyPiWdx5840omRIifEe9nD0bDyd6T9TZ3FESZAa4yrsfvTseDrTf4hSISeGzAURohTKjSEpQYQomUpiSGoQIUqi0hiSE0SIMqMmhuQGEaKcUBtDSoIIUY60iCGlQYQog1YxpCaI7D5KyxhSG0R2G+U67Cts6sJxOTGkRRBJiaJ31x26chv2HTZ14dwY0iqInIuid9a7d2kqSkkMaRlEpqJ0HSMaRymNIa2DyDjKLmJEuuhrWGkMWSKI6J30bruJ0cpSQVCIIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZggCAACAjd2FfXY+3fFinPtG6GUX9a1DEDMEMUMQMwQxcxP21vl0RwAAAAAAAGCnDod/1p4xx4l+w0cAAAAASUVORK5CYII=');
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 32px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 600px;
|
||||
width: calc(100% - 80px);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.display {
|
||||
border: none;
|
||||
background-size: contain;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
|
@ -45,3 +71,84 @@ fileupload {
|
|||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
dialogue {
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
width: calc(100% - 40px);
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
dialogue h2 {
|
||||
background: $secondary-dark-bg;
|
||||
color: $secondary-dark-fg;
|
||||
font-size: 1.5em;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
dialogue p {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
dialogue .buttons {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
dialogue button {
|
||||
border: 1px solid $secondary-dark-bg;
|
||||
background: transparent;
|
||||
color: $secondary-dark-bg;
|
||||
padding: 5px 15px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
dialogue button.alert {
|
||||
border-color: red;
|
||||
color: red;
|
||||
}
|
||||
|
||||
dialogue button.cancel {
|
||||
border-color: #999;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
newsentry {
|
||||
display: flex;
|
||||
color: $meta-fg;
|
||||
font-size: 12px;
|
||||
|
||||
a {
|
||||
&.cover {
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
|
||||
img {
|
||||
max-height: 70px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&.nobg {
|
||||
height: 70px;
|
||||
width: 124px;
|
||||
background: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
.entrycontent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 2 1 auto;
|
||||
padding: 0 5px 5px;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 10px !important;
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
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 = {
|
||||
uploadFile(vnode, event) {
|
||||
if (!event.target.files[0]) return
|
||||
vnode.state.updateError(vnode, '')
|
||||
vnode.state.loading = true
|
||||
|
||||
uploadMedia(event.target.files[0])
|
||||
.then(function(res) {
|
||||
vnode.state.media = res
|
||||
console.log(vnode.state.media)
|
||||
if (vnode.attrs.onupload) {
|
||||
vnode.attrs.onupload(res)
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.log(err)
|
||||
vnode.state.updateError(vnode, err.message)
|
||||
})
|
||||
.then(function() {
|
||||
vnode.state.loading = false
|
||||
|
@ -20,29 +22,43 @@ const FileUpload = {
|
|||
})
|
||||
},
|
||||
|
||||
updateError: function(vnode, error) {
|
||||
if (vnode.attrs.onerror) {
|
||||
vnode.attrs.onerror(error)
|
||||
} else {
|
||||
vnode.state.error = error
|
||||
}
|
||||
},
|
||||
|
||||
oninit: function(vnode) {
|
||||
vnode.state.loading = false
|
||||
vnode.state.media = null
|
||||
vnode.state.error = ''
|
||||
},
|
||||
|
||||
view: function(vnode) {
|
||||
let media = vnode.state.media
|
||||
let media = vnode.attrs.media
|
||||
|
||||
return m('fileupload', [
|
||||
(media ?
|
||||
m('a.display', {
|
||||
return m('fileupload', {
|
||||
class: vnode.attrs.class || null,
|
||||
}, [
|
||||
m('div.error', {
|
||||
hidden: !vnode.state.error,
|
||||
}, vnode.state.error),
|
||||
(media
|
||||
? vnode.attrs.useimg
|
||||
? [ m('img', { src: media.large_url }), m('div.showicon')]
|
||||
: m('a.display.inside', {
|
||||
href: media.large_url,
|
||||
style: {
|
||||
'background-image': 'url(' + media.medium_url + ')',
|
||||
}
|
||||
}) :
|
||||
m('div.showicon')
|
||||
},
|
||||
}, m('div.showicon'))
|
||||
: m('div.inside.showbordericon')
|
||||
),
|
||||
m('input', {
|
||||
accept: 'image/*',
|
||||
type: 'file',
|
||||
onchange: FileUpload.uploadFile.bind(this, vnode),
|
||||
onchange: this.uploadFile.bind(this, vnode),
|
||||
}),
|
||||
(vnode.state.loading ? m('div.loading-spinner') : null),
|
||||
])
|
||||
|
|
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",
|
||||
"database" : "nfpmoe"
|
||||
},
|
||||
"connectionslave": null,
|
||||
"migrations": {
|
||||
}
|
||||
},
|
||||
"acquireConnectionTimeout": 10000
|
||||
},
|
||||
"bunyan": {
|
||||
"name": "nfpmoe",
|
||||
|
@ -37,6 +39,8 @@
|
|||
"bcrypt": 5,
|
||||
"fileSize": 524288000,
|
||||
"upload": {
|
||||
"port": "2111",
|
||||
"host": "storage01.nfp.is",
|
||||
"name": "nfpmoe-dev",
|
||||
"secret": "nfpmoe-dev"
|
||||
}
|
||||
|
|
19
index.mjs
19
index.mjs
|
@ -3,12 +3,17 @@ import log from './api/log'
|
|||
// Run the database script automatically.
|
||||
import setup from './api/setup'
|
||||
|
||||
setup().then(() =>
|
||||
setup().catch(async (error) => {
|
||||
log.error({ code: error.code, message: error.message }, 'Error while preparing database')
|
||||
log.error('Unable to verify database integrity.')
|
||||
log.warn('Continuing anyways')
|
||||
// import('./api/config').then(module => {
|
||||
// log.error(error, 'Error while preparing database')
|
||||
// log.error({ config: module.default.get() }, 'config used')
|
||||
// process.exit(1)
|
||||
// })
|
||||
}).then(() =>
|
||||
import('./server')
|
||||
).catch((error) => {
|
||||
import('./api/config').then(module => {
|
||||
log.error(error, 'Error while preparing database')
|
||||
log.error({ config: module.default.get() }, 'config used')
|
||||
process.exit(1)
|
||||
})
|
||||
).catch(error => {
|
||||
log.error(error, 'Unknown error starting server')
|
||||
})
|
||||
|
|
|
@ -29,8 +29,72 @@ exports.up = function up(knex, Promise) {
|
|||
table.text('medium_image')
|
||||
table.text('large_image')
|
||||
table.integer('size')
|
||||
table.integer('login_id')
|
||||
table.integer('staff_id')
|
||||
.references('staff.id')
|
||||
table.boolean('is_deleted')
|
||||
.notNullable()
|
||||
.default(false)
|
||||
table.timestamps()
|
||||
}),
|
||||
knex.schema.createTable('files', function(table) {
|
||||
table.increments()
|
||||
table.text('filename')
|
||||
table.text('filetype')
|
||||
table.integer('size')
|
||||
table.integer('staff_id')
|
||||
.references('staff.id')
|
||||
table.boolean('is_deleted')
|
||||
.notNullable()
|
||||
.default(false)
|
||||
table.timestamps()
|
||||
}),
|
||||
knex.schema.createTable('pages', function(table) {
|
||||
table.increments()
|
||||
table.integer('staff_id')
|
||||
.references('staff.id')
|
||||
table.integer('parent_id')
|
||||
.references('pages.id')
|
||||
table.text('name')
|
||||
table.text('path')
|
||||
table.text('description')
|
||||
table.integer('banner_id')
|
||||
.references('media.id')
|
||||
.defaultTo(null)
|
||||
table.integer('media_id')
|
||||
.references('media.id')
|
||||
.defaultTo(null)
|
||||
table.boolean('is_deleted')
|
||||
.notNullable()
|
||||
.default(false)
|
||||
table.timestamps()
|
||||
}),
|
||||
knex.schema.createTable('articles', function(table) {
|
||||
table.increments()
|
||||
table.integer('staff_id')
|
||||
.references('staff.id')
|
||||
table.integer('parent_id')
|
||||
.references('pages.id')
|
||||
table.text('name')
|
||||
table.text('path')
|
||||
table.text('description')
|
||||
table.integer('banner_id')
|
||||
.references('media.id')
|
||||
.defaultTo(null)
|
||||
table.integer('media_id')
|
||||
.references('media.id')
|
||||
.defaultTo(null)
|
||||
table.boolean('is_deleted')
|
||||
.notNullable()
|
||||
.default(false)
|
||||
table.timestamps()
|
||||
}),
|
||||
knex.schema.createTable('files', function(table) {
|
||||
table.increments()
|
||||
table.integer('file_id')
|
||||
.references('files.id')
|
||||
table.text('filename')
|
||||
table.text('filetype')
|
||||
table.integer('size')
|
||||
table.integer('staff_id')
|
||||
.references('staff.id')
|
||||
table.boolean('is_deleted')
|
||||
|
|
18
package.json
18
package.json
|
@ -9,10 +9,11 @@
|
|||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"start": "node --experimental-modules index.mjs",
|
||||
"build": "sass app/app.scss public/assets/app.css && browserify -d app/index.js -o public/assets/app.js",
|
||||
"build": "sass -s compressed app/app.scss public/assets/app.css && browserify -p tinyify --bare --no-browser-field -o public/assets/app.js app/index.js",
|
||||
"build:check": "browserify -t uglifyify --bare --no-browser-field --list app/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"watch:api": "nodemon --experimental-modules index.mjs | bunyan",
|
||||
"watch:app": "watchify -d app/index.js -o public/assets/app.js",
|
||||
"watch:app": "watchify -g envify -d app/index.js -o public/assets/app.js",
|
||||
"watch:sass": "sass --watch app/app.scss public/assets/app.css",
|
||||
"dev": "run-p watch:api watch:app watch:sass",
|
||||
"prod": "npm run build && npm start"
|
||||
|
@ -29,11 +30,13 @@
|
|||
"homepage": "https://github.com/nfp-projects/nfp_moe",
|
||||
"dependencies": {
|
||||
"@koa/cors": "^2.2.3",
|
||||
"bookshelf": "^0.14.2",
|
||||
"bookshelf": "^0.15.1",
|
||||
"bunyan-lite": "^1.0.1",
|
||||
"dot": "^1.1.2",
|
||||
"format-link-header": "^2.1.0",
|
||||
"googleapis": "^37.2.0",
|
||||
"googleapis": "^42.0.0",
|
||||
"http-errors": "^1.7.2",
|
||||
"json-mask": "^0.3.8",
|
||||
"jsonwebtoken": "^8.4.0",
|
||||
"knex": "^0.16.3",
|
||||
"koa": "^2.7.0",
|
||||
|
@ -44,17 +47,18 @@
|
|||
"lodash": "^4.17.11",
|
||||
"multer": "^1.4.1",
|
||||
"nconf": "^0.10.0",
|
||||
"parse-torrent": "^7.0.1",
|
||||
"pg": "^7.8.0",
|
||||
"sharp": "^0.21.3",
|
||||
"socks5-http-client": "^1.0.4"
|
||||
"sharp": "^0.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"browserify": "^16.2.3",
|
||||
"eslint": "^5.14.1",
|
||||
"mithril": "^2.0.0-rc.4",
|
||||
"mithril": "^2.0.3",
|
||||
"nodemon": "^1.18.10",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"sass": "^1.17.0",
|
||||
"tinyify": "^2.5.1",
|
||||
"watchify": "^3.11.0"
|
||||
}
|
||||
}
|
||||
|
|
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 log from './api/log'
|
||||
import { serve } from './api/serve'
|
||||
import { mask } from './api/middlewares/mask'
|
||||
import { errorHandler } from './api/error/middleware'
|
||||
import { accessChecks } from './api/access/middleware'
|
||||
import ParserMiddleware from './api/parser/middleware'
|
||||
|
@ -14,12 +15,13 @@ import ParserMiddleware from './api/parser/middleware'
|
|||
const app = new Koa()
|
||||
const parser = new ParserMiddleware()
|
||||
|
||||
app.use(log.logMiddleware())
|
||||
app.use(errorHandler())
|
||||
app.use(mask())
|
||||
app.use(bodyParser())
|
||||
app.use(parser.contextParser())
|
||||
app.use(accessChecks())
|
||||
app.use(parser.generateLinks())
|
||||
app.use(log.logMiddleware())
|
||||
app.use(Jwt.jwtMiddleware())
|
||||
app.use(cors({
|
||||
exposeHeaders: ['link', 'pagination_total'],
|
||||
|
|
Loading…
Reference in a new issue