Development

This commit is contained in:
Jonatan Nilsson 2022-07-20 00:33:06 +00:00
parent a666e1d351
commit 15a4020d12
33 changed files with 1270 additions and 668 deletions

View file

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

@ -0,0 +1,86 @@
import { parseFiles } from '../file/util.mjs'
import { upload } from '../media/upload.mjs'
export default class ArticleRoutes {
constructor(opts = {}) {
Object.assign(this, {
upload: upload,
})
}
/** GET: /api/articles/[path] */
async getArticle(ctx) {
let res = await ctx.db.safeCallProc('article_get_single', [ctx.params.path])
let out = {
article: res.results[0][0] || null,
files: parseFiles(res.results[1]),
}
ctx.body = out
}
/** GET: /api/auth/articles */
async auth_getAllArticles(ctx) {
let res = await ctx.db.safeCallProc('article_auth_get_all', [
ctx.state.auth_token,
Math.max(ctx.query.get('page') || 1, 1),
Math.min(ctx.query.get('per_page') || 10, 25)
])
let out = {
articles: res.results[0],
total_articles: res.results[0][0].total_articles,
}
ctx.body = out
}
/** GET: /api/auth/articles/:path */
async auth_getSingleArticle(ctx) {
let res = await ctx.db.safeCallProc('article_auth_get_update_create', [
ctx.state.auth_token,
ctx.params.path
])
let out = {
article: res.results[0][0] || null,
files: parseFiles(res.results[1]),
staff: res.results[2],
}
ctx.body = out
}
/** PUT: /api/auth/articles/:path */
async auth_updateCreateSingleArticle(ctx) {
console.log(ctx.req.files)
console.log(ctx.req.body)
let newBanner = null
let newMedia = null
let promises = []
if (ctx.req.files.banner) {
promises.push(
this.upload(ctx.req.files.banner)
.then(res => { newBanner = res })
)
}
if (ctx.req.files.media && false) {
promises.push(
this.upload(ctx.req.files.media)
.then(res => { newMedia = res })
)
}
await Promise.all(promises)
console.log(newBanner)
console.log(newMedia)
ctx.body = {}
}
}

View file

@ -0,0 +1,30 @@
import crypto from 'crypto'
import * as util from '../util.mjs'
import config from '../config.mjs'
export default class AuthenticationRoutes {
constructor(opts = {}) {
Object.assign(this, {
secret: config.get('jwtsecret'),
crypto: crypto,
util: util,
})
}
/** GET: /api/authentication/login */
async login(ctx) {
let res = await ctx.db.safeCallProc('dbo.common_auth_login', [
ctx.req.body.email,
ctx.req.body.password,
])
let out = res.results[0][0]
const hmac = this.crypto.createHmac('sha256', this.secret)
hmac.update(out.token)
let apiSignature = this.util.encode(hmac.digest())
out.token = out.token + '.' + apiSignature
ctx.body = out
}
}

View file

@ -0,0 +1,62 @@
import crypto from 'crypto'
import { HttpError } from 'flaska'
import { decode, encode } from '../util.mjs'
import config from '../config.mjs'
const levels = {
Normal: 1,
Manager: 10,
Admin: 100,
}
const issuer = config.get('mssql:connectionUser')
const secret = config.get('jwtsecret')
export function authenticate(minLevel = levels.Manager) {
return function(ctx) {
if (!ctx.req.headers.authorization) {
throw new HttpError(401, 'Authentication token missing')
}
if (!ctx.req.headers.authorization.startsWith('Bearer ')) {
throw new HttpError(401, 'Authentication token invalid')
}
let parts = ctx.req.headers.authorization.slice(7).split('.')
if (parts.length !== 4) {
throw new HttpError(401, 'Authentication token invalid')
}
const hmac = crypto.createHmac('sha256', secret)
const token = [parts[0], parts[1], parts[2]].join('.')
hmac.update(token)
let apiSignature = encode(hmac.digest())
if (apiSignature !== parts[3]) {
throw new HttpError(401, 'Authentication token invalid signature')
}
let header
let body
try {
header = JSON.parse(decode(parts[0]).toString('utf8'))
body = JSON.parse(decode(parts[1]).toString('utf8'))
} catch (err) {
throw new HttpError(401, 'Authentication token invalid json')
}
if (header.alg !== 'HS256') {
throw new HttpError(401, 'Authentication token invalid alg')
}
let unixNow = Math.floor(Date.now() / 1000)
// Validate token, add a little skew support for issued_at
if (body.iss !== issuer || !body.iat || !body.exp
|| body.iat > unixNow + 300 || body.exp <= unixNow) {
throw new HttpError(403, 'Authentication token expired or invalid')
}
ctx.state.auth_user = body
ctx.state.auth_token = token
}
}

View file

@ -54,6 +54,7 @@ nconf.defaults({
"frontend": {
"url": "http://beta01.nfp.moe"
},
"jwtsecret": "w2bkdWAButfdfEkCs8dpE3L2n6QzCfhna0T4",
"mssql": {
"conn_timeout": 5,
"floor": 1,
@ -62,6 +63,114 @@ nconf.defaults({
"inactivityTimeoutSecs": 60,
"connectionString": "Driver={ODBC Driver 17 for SQL Server}; Server=localhost;UID=dev; PWD=dev; Database=nfp_moe",
},
"media": {
"secret": "upload-secret-key-here",
"iss": "dev",
"path": "https://media.nfp.is/media/resize",
"preview": {
"out": "base64",
"format": "avif",
"blur": 10,
"resize": {
"width": 300,
"height": 300,
"fit": "inside",
"withoutEnlargement": true,
"kernel": "lanczos3"
},
"avif": {
"quality": 50,
"effort": 4
}
},
"small": {
"jpeg": {
"format": "jpeg",
"resize": {
"width": 720,
"height": 720,
"fit": "inside",
"withoutEnlargement": true,
"kernel": "lanczos3"
},
"jpeg": {
"quality": 93
}
},
"avif": {
"format": "avif",
"resize": {
"width": 720,
"height": 720,
"fit": "inside",
"withoutEnlargement": true,
"kernel": "lanczos3"
},
"avif": {
"quality": 60,
"effort": 3
}
}
},
"medium": {
"jpeg": {
"format": "jpeg",
"resize": {
"width": 1300,
"height": 1300,
"fit": "inside",
"withoutEnlargement": true,
"kernel": "lanczos3"
},
"jpeg": {
"quality": 93
}
},
"avif": {
"format": "avif",
"resize": {
"width": 1300,
"height": 1300,
"fit": "inside",
"withoutEnlargement": true,
"kernel": "lanczos3"
},
"avif": {
"quality": 75,
"effort": 3
}
}
},
"large": {
"jpeg": {
"format": "jpeg",
"resize": {
"width": 3000,
"height": 3000,
"fit": "inside",
"withoutEnlargement": true,
"kernel": "lanczos3"
},
"jpeg": {
"quality": 95
}
},
"avif": {
"format": "avif",
"resize": {
"width": 3000,
"height": 3000,
"fit": "inside",
"withoutEnlargement": true,
"kernel": "lanczos3"
},
"avif": {
"quality": 85,
"effort": 3
}
}
},
},
"fileSize": 524288000,
"upload": {
"baseurl": "https://cdn.nfp.is",

View file

@ -42,9 +42,18 @@ export function initPool(core, config) {
return {
safeCallProc: function(name, params, options) {
return pool.promises.callProc(config.schema + '.' + name, params, options)
if (name.indexOf('.') < 0) {
name = config.schema + '.' + name
}
return pool.promises.callProc(name, params, options)
.catch(function(err) {
let message = err.message
let message = err.message.replace(/\[[^\]]+\]/g, '')
if (err.code > 50000) {
if (err.code === 51001) {
throw new HttpError(403, message)
}
throw new HttpError(422, message)
}
if (err.lineNumber && err.procName) {
message = `Error at ${err.procName}:${err.lineNumber} => ${message}`
}

20
api/file/util.mjs Normal file
View file

@ -0,0 +1,20 @@
export function parseFiles(files) {
for (let i = 0; i < files.length; i++) {
parseFile(files[i])
}
return files
}
export function parseFile(file) {
file.url = 'https://cdn.nfp.is' + file.path
file.magnet = null
file.meta = JSON.parse(file.meta || '{}') || {}
if (file.meta.torrent) {
file.magnet = 'magnet:?'
+ 'xl=' + file.size
+ '&dn=' + encodeURIComponent(file.meta.torrent.name)
+ '&xt=urn:btih:' + file.meta.torrent.hash
+ file.meta.torrent.announce.map(item => ('&tr=' + encodeURIComponent(item))).join('')
}
return file
}

200
api/media/client.mjs Normal file
View file

@ -0,0 +1,200 @@
import crypto from 'crypto'
import fs from 'fs/promises'
import path from 'path'
import http from 'http'
import https from 'https'
import { URL } from 'url'
import { encode } from '../util.mjs'
export default class Client {
constructor() {
}
customRequest(method = 'GET', path, body, options = {}) {
if (path.slice(0, 4) !== 'http') {
throw new Error('Http client path invalid')
}
let urlObj = new URL(path)
let d1 = new Date()
return new Promise((resolve, reject) => {
const opts = {
method: options.method || method,
timeout: options.timeout || 60000,
protocol: options.protocol || urlObj.protocol,
username: options.username || urlObj.username,
password: options.password || urlObj.password,
host: options.host || urlObj.hostname,
port: options.port || Number(urlObj.port),
path: options.path || urlObj.pathname + urlObj.search,
headers: options.headers || {},
}
if (options.agent) {
opts.agent = options.agent
}
// opts.agent = agent
let req
if (path.startsWith('https')) {
req = https.request(opts)
} else {
req = http.request(opts)
}
req.on('error', (err) => {
reject(err)
})
req.on('timeout', function() {
console.log("req.on('timeout')")
req.destroy()
let d2 = new Date()
console.log((d2 - d1))
reject(new Error(`Request ${method} ${path} timed out`))
})
req.on('response', res => {
let output = ''
res.setEncoding('utf8')
res.on('data', function (chunk) {
output += chunk.toString()
})
res.on('end', function () {
if (options.getRaw) {
output = {
status: res.statusCode,
data: output,
headers: res.headers,
}
} else {
if (!output) return resolve(null)
try {
output = JSON.parse(output)
} catch (e) {
return reject(new Error(`${e.message} while decoding: ${output}`))
}
}
if (!options.getRaw && output.status && typeof(output.status) === 'number') {
let err = new Error(`Request failed [${output.status}]: ${output.message}`)
err.body = output
return reject(err)
}
resolve(output)
})
})
if (opts.returnRequest) {
return resolve(req)
}
if (body) {
req.write(body)
}
req.end()
return req
})
}
createJwt(body, secret) {
let header = {
typ: 'JWT',
alg: 'HS256',
}
body.iat = Math.floor(Date.now() / 1000)
body.exp = body.iat + 300
// Base64 encode header and body
let headerBase64 = encode(Buffer.from(JSON.stringify(header)))
let bodyBase64 = encode(Buffer.from(JSON.stringify(body)))
let headerBodyBase64 = headerBase64 + '.' + bodyBase64
const hmac = crypto.createHmac('sha256', secret)
hmac.update(headerBodyBase64)
let signatureBuffer = hmac.digest()
// Construct final JWT
let signatureBase64 = encode(signatureBuffer)
return headerBodyBase64 + '.' + signatureBase64
}
random(length = 8) {
// Declare all characters
let chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
// Pick characers randomly
let str = '';
for (let i = 0; i < length; i++) {
str += chars.charAt(Math.floor(Math.random() * chars.length));
}
return str;
}
post(url, body) {
let parsed = JSON.stringify(body)
return this.customRequest('POST', url, parsed, {
headers: {
'Content-Type': 'application/json',
'Content-Length': parsed.length,
},
})
}
upload(url, files, method = 'POST', body = {}) {
const boundary = `---------${this.random(32)}`
const crlf = '\r\n'
let upload = files
if (typeof(upload) === 'string') {
upload = {
file: files
}
}
let keys = Object.keys(upload)
let uploadBody = []
return Promise.all(keys.map(key => {
let file = upload[key]
let filename = ''
if (typeof(file) === 'object') {
if (typeof(file.file) !== 'string' || typeof(file.filename) !== 'string') {
throw new Error('Invalid value in client.upload for key ' + key)
}
filename = file.filename
file = file.file
} else {
filename = path.basename(file)
}
return fs.readFile(file).then(data => {
uploadBody.push(Buffer.from(
`${crlf}--${boundary}${crlf}` +
`Content-Disposition: form-data; name="${key}"; filename="${filename}"` + crlf + crlf
))
uploadBody.push(data)
})
}))
.then(() => {
uploadBody.push(
Buffer.concat(Object.keys(body).map(function(key) {
return Buffer.from(''
+ `${crlf}--${boundary}${crlf}`
+ `Content-Disposition: form-data; name="${key}"` + crlf + crlf
+ JSON.stringify(body[key])
)
}))
)
uploadBody.push(Buffer.from(`${crlf}--${boundary}--`))
let multipartBody = Buffer.concat(uploadBody)
return this.customRequest(method, url, multipartBody, {
headers: {
'Content-Type': 'multipart/form-data; boundary=' + boundary,
'Content-Length': multipartBody.length,
},
})
})
}
}

51
api/media/upload.mjs Normal file
View file

@ -0,0 +1,51 @@
import config from '../config.mjs'
import Client from './client.mjs'
export function upload(file) {
const media = config.get('media')
const client = new Client()
let token = client.createJwt({ iss: media.iss }, media.secret)
let out = {
small: {},
medium: {},
large: {},
}
return client.upload(media.path + '?token=' + token, { file: {
file: file.path,
filename: file.name,
} }, 'POST', {
preview: media.preview,
small: media.small.avif,
medium: media.medium.avif,
large: media.large.avif,
/*
small: media.small.jpeg,
medium: media.medium.avif,
large_jpeg: media.large.jpeg,*/
}).then(res => {
out.filename = res.filename
out.path = res.path
out.preview = res.preview
out.small.avif = res.small
out.medium.avif = res.medium
out.large.avif = res.large
return client.post(media.path + '/' + out.filename + '?token=' + token, {
small: media.small.jpeg,
medium: media.medium.jpeg,
large: media.large.jpeg,
})
.then(res => {
out.small.jpeg = res.small
out.medium.jpeg = res.medium
out.large.jpeg = res.large
})
})
.then(() => {
return out
})
}

View file

@ -1,26 +1,7 @@
/*
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,
}
*/
import { parseFile } from '../file/util.mjs'
export async function getTree(ctx) {
let res = await ctx.db.safeCallProc('pages_gettree', [])
let res = await ctx.db.safeCallProc('pages_get_tree', [])
let out = []
let children = []
let map = new Map()
@ -45,7 +26,22 @@ export async function getTree(ctx) {
}
export async function getPage(ctx, path, page = 0, per_page = 10) {
let res = await ctx.db.safeCallProc('pages_getpage', [path, page, per_page])
console.log([path, page, per_page])
console.log(res.results)
let res = await ctx.db.safeCallProc('pages_get_single', [path, page, per_page])
let articleMap = new Map()
let out = {
page: res.results[0][0] || null,
articles: res.results[1],
total_articles: res.results[2][0].total_articles,
featured: res.results[4][0] || null
}
out.articles.forEach(article => {
article.files = []
articleMap.set(article.id, article)
})
res.results[3].forEach(file => {
articleMap.get(file.id).files.push(parseFile(file))
})
return out
}

View file

@ -14,13 +14,13 @@ export default class PageRoutes {
ctx.body = await this.Page.getTree(ctx)
}
/** GET: /api/page/[path] */
/** GET: /api/pages/[path] */
async getPage(ctx) {
ctx.body = await this.Page.getPage(
ctx,
ctx.params.path || null,
ctx.query.get('page') || 1,
ctx.query.get('per_page') || 10
Math.max(ctx.query.get('page') || 1, 1),
Math.min(ctx.query.get('per_page') || 10, 25)
)
}
}

View file

@ -1,17 +1,22 @@
import { Flaska, QueryHandler } from 'flaska'
import { Flaska, QueryHandler, JsonHandler, FormidableHandler } from 'flaska'
import formidable from 'formidable'
import { initPool } from './db.mjs'
import config from './config.mjs'
import PageRoutes from './page/routes.mjs'
import ServeHandler from './serve.mjs'
// import ArticleRoutes from './article/routes.mjs'
import ParserMiddleware from './pagination/parser.mjs'
import ArticleRoutes from './article/routes.mjs'
import AuthenticationRoutes from './authentication/routes.mjs'
import { authenticate } from './authentication/security.mjs'
export function run(http, port, core) {
let localUtil = new core.sc.Util(import.meta.url)
// Create our server
const flaska = new Flaska({
appendHeaders: {
'Content-Security-Policy': `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data: blob:; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'`,
},
log: core.log,
nonce: ['script-src'],
nonceCacheLength: 50,
@ -25,16 +30,12 @@ export function run(http, port, core) {
flaska.devMode()
}
const parser = new ParserMiddleware()
flaska.before(function(ctx) {
ctx.state.started = new Date().getTime()
ctx.db = pool
})
flaska.before(QueryHandler())
flaska.before(parser.contextParser())
//
flaska.after(function(ctx) {
let ended = new Date().getTime()
var requestTime = ended - ctx.state.started
@ -61,11 +62,16 @@ export function run(http, port, core) {
flaska.get('/api/pages/:path', page.getPage.bind(page))
// flaska.get('/api/pages/:pageId', page.getSinglePage.bind(page))
// const article = new ArticleRoutes()
// flaska.get('/api/articles/public', article.getPublicAllArticles.bind(article))
// flaska.get('/api/articles/public/:id', article.getPublicSingleArticle.bind(article))
const article = new ArticleRoutes()
flaska.get('/api/articles/:path', article.getArticle.bind(article))
flaska.get('/api/auth/articles', authenticate(), article.auth_getAllArticles.bind(article))
flaska.get('/api/auth/articles/:path', authenticate(), article.auth_getSingleArticle.bind(article))
flaska.put('/api/auth/articles/:path', [authenticate(), FormidableHandler(formidable) ], article.auth_updateCreateSingleArticle.bind(article))
// flaska.get('/api/pages/:pageId/articles/public', article.getPublicAllPageArticles.bind(article))
const authentication = new AuthenticationRoutes()
flaska.post('/api/authentication/login', JsonHandler(), authentication.login.bind(authentication))
const serve = new ServeHandler({
root: localUtil.getPathFromRoot('../public'),
version: core.app.running,

21
api/util.mjs Normal file
View file

@ -0,0 +1,21 @@
export function encode(buffer) {
return buffer
.toString('base64')
.replace(/\+/g, '-') // Convert '+' to '-'
.replace(/\//g, '_') // Convert '/' to '_'
.replace(/=+$/, '') // Remove ending '='
}
export function decode(base64StringUrlSafe) {
let base64String = base64StringUrlSafe.replace(/-/g, '+').replace(/_/g, '/')
switch (base64String.length % 4) {
case 2:
base64String += '=='
break
case 3:
base64String += '='
break
}
return Buffer.from(base64String, 'base64')
}

View file

@ -2,44 +2,71 @@ const Article = require('../api/article')
const pagination = require('../api/pagination')
const Dialogue = require('../widgets/dialogue')
const Pages = require('../widgets/pages')
const common = require('../api/common')
const AdminArticles = {
oninit: function(vnode) {
this.error = ''
this.lastpage = m.route.param('page') || '1'
this.articles = []
this.loading = false
this.showLoading = null
this.data = {
articles: [],
total_articles: 0,
}
this.removeArticle = null
this.currentPage = Number(m.route.param('page')) || 1
this.fetchArticles(vnode)
},
onupdate: function(vnode) {
if (m.route.param('page') && m.route.param('page') !== this.lastpage) {
onbeforeupdate: function(vnode) {
this.currentPage = Number(m.route.param('page')) || 1
if (this.currentPage !== this.lastpage) {
this.fetchArticles(vnode)
}
},
fetchArticles: function(vnode) {
this.loading = true
this.links = null
this.lastpage = m.route.param('page') || '1'
this.error = ''
this.lastpage = this.currentPage
document.title = 'Articles Page ' + this.lastpage + ' - Admin NFP Moe'
return pagination.fetchPage(Article.getAllArticlesPagination({
per_page: 20,
page: this.lastpage,
includes: ['parent', 'staff'],
}))
.then(function(result) {
vnode.state.articles = result.data
vnode.state.links = result.links
if (this.showLoading) {
clearTimeout(this.showLoading)
}
if (this.data.articles.length) {
this.showLoading = setTimeout(() => {
this.showLoading = null
this.loading = true
m.redraw()
}, 150)
} else {
this.loading = true
}
return common.sendRequest({
method: 'GET',
url: '/api/auth/articles?page=' + (this.lastpage || 1),
})
.catch(function(err) {
vnode.state.error = err.message
.then((result) => {
console.log(result)
this.data = result
this.data.articles.forEach((article) => {
article.hidden = new Date() < new Date(article.publish_at)
article.page_path = article.page_path ? '/page/' + article.page_path : '/'
article.page_name = article.page_name || 'Frontpage'
})
.then(function() {
vnode.state.loading = false
}, (err) => {
this.error = err.message
})
.then(() => {
clearTimeout(this.showLoading)
this.showLoading = null
this.loading = false
m.redraw()
})
},
@ -58,31 +85,19 @@ const AdminArticles = {
},
drawArticle: function(vnode, article) {
let parent
if (article.parent) {
parent = {
path: '/page/' + article.parent.path,
name: article.parent.name,
}
} else {
parent = {
path: '/',
name: '-- Frontpage --',
}
}
let className = ''
if (new Date() < new Date(article.published_at)) {
className = 'rowhidden'
} else if (article.is_featured) {
className = 'rowfeatured'
}
return [
m('tr', { class: className }, [
m('td', m(m.route.Link, { href: '/admin/articles/' + article.id }, article.name)),
m('td', m(m.route.Link, { href: parent.path }, parent.name)),
m('tr', {
class: article.hidden
? rowhidden
: article.is_featured
? 'rowfeatured'
: ''
}, [
m('td', m(m.route.Link, { href: '/admin/articles/' + article.path }, article.name)),
m('td', m(m.route.Link, { href: article.page_path }, article.page_name)),
m('td', m(m.route.Link, { href: '/article/' + article.path }, '/article/' + article.path)),
m('td.right', article.published_at.replace('T', ' ').split('.')[0]),
m('td.right', article.staff && article.staff.fullname || 'Admin'),
m('td.right', article.publish_at.replace('T', ' ').split('.')[0]),
m('td.right', article.admin_name),
m('td.right', m('button', { onclick: function() { vnode.state.removeArticle = article } }, 'Remove')),
]),
]
@ -101,7 +116,7 @@ const AdminArticles = {
hidden: !this.error,
onclick: function() { vnode.state.error = '' },
}, this.error),
(this.loading
this.loading
? m('div.loading-spinner.full')
: m('table', [
m('thead',
@ -114,13 +129,13 @@ const AdminArticles = {
m('th.right', 'Actions'),
])
),
m('tbody', this.articles.map(AdminArticles.drawArticle.bind(this, vnode))),
])
m('tbody', this.data.articles.map((article) => this.drawArticle(vnode, article))),
],
),
m(Pages, {
/*m(Pages, {
base: '/admin/articles',
links: this.links,
}),
}),*/
]),
]),
m(Dialogue, {

View file

@ -6,189 +6,199 @@ const Page = require('../api/page')
const File = require('../api/file')
const Fileinfo = require('../widgets/fileinfo')
const Article = require('../api/article')
const common = require('../api/common')
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.froala = null
this.loadedFroala = Froala.loadedFroala
this.staffers = []
this.editor = null
Staff.getAllStaff()
.then(function(result) {
vnode.state.staffers = result
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
m.redraw()
})
if (!this.loadedFroala) {
Froala.createFroalaScript()
.then(function() {
vnode.state.loadedFroala = true
m.redraw()
})
this.loading = false
this.showLoading = null
this.data = {
article: null,
files: [],
staff: [],
}
this.pages = [{id: null, name: 'Frontpage'}]
this.addPageTree('', Page.Tree)
this.newBanner = null
this.newMedia = null
this.fetchArticle(vnode)
},
onupdate: function(vnode) {
addPageTree: function(prefix, branches) {
branches.forEach((page) => {
this.pages.push({ id: page.id, name: prefix + page.name })
if (page.children && page.children.length) {
this.addPageTree(page.name + ' -> ', page.children)
}
})
},
onbeforeupdate: function(vnode) {
if (this.lastid !== m.route.param('id')) {
this.fetchArticle(vnode)
if (this.lastid === 'add') {
m.redraw()
}
}
},
fetchArticle: function(vnode) {
this.lastid = m.route.param('id')
this.loading = this.lastid !== 'add'
this.creating = this.lastid === 'add'
this.loadingFile = false
this.error = ''
this.article = {
name: '',
path: '',
description: '',
media: null,
banner: null,
files: [],
is_featured: false,
published_at: new Date(new Date().setFullYear(3000)).toISOString(),
}
this.editedPath = false
this.loadedFroala = Froala.loadedFroala
if (this.lastid !== 'add') {
Article.getArticle(this.lastid)
.then(function(result) {
vnode.state.editedPath = true
vnode.state.article = result
EditArticle.parsePublishedAt(vnode, null)
document.title = 'Editing: ' + result.name + ' - Admin NFP Moe'
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
vnode.state.loading = false
if (vnode.state.froala) {
vnode.state.froala.html.set(vnode.state.article.description)
if (this.showLoading) {
clearTimeout(this.showLoading)
}
if (this.data.article) {
this.showLoading = setTimeout(() => {
this.showLoading = null
this.loading = true
m.redraw()
}, 150)
} else {
this.loading = true
}
return common.sendRequest({
method: 'GET',
url: '/api/auth/articles/' + this.lastid,
})
.then((result) => {
console.log('result', result)
this.data = result
if (this.data.article) {
this.data.article.publish_at = new Date(this.data.article.publish_at)
document.title = 'Editing: ' + this.data.article.name + ' - Admin NFP Moe'
} else {
document.title = 'Create Article - Admin NFP Moe'
}
}, (err) => {
this.error = err.message
})
.then(() => {
clearTimeout(this.showLoading)
this.showLoading = null
this.loading = false
m.redraw()
})
} else {
EditArticle.parsePublishedAt(vnode, null)
document.title = 'Create Article - Admin NFP Moe'
if (vnode.state.froala) {
vnode.state.froala.html.set(this.article.description)
}
}
},
parsePublishedAt: function(vnode, date) {
vnode.state.article.published_at = ((date && date.toISOString() || vnode.state.article.published_at).split('.')[0]).substr(0, 16)
},
updateValue: function(name, e) {
if (name === 'is_featured') {
this.article[name] = e.currentTarget.checked
this.data.article[name] = e.currentTarget.checked
} else {
this.article[name] = e.currentTarget.value
this.data.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, '-')
this.data.article.path = this.data.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
}
this.data.article.page_id = Number(e.currentTarget.value) || null
},
updateStaffer: function(e) {
this.article.staff_id = Number(e.currentTarget.value)
this.data.article.admin_id = Number(e.currentTarget.value)
},
mediaUploaded: function(type, media) {
this.article[type] = media
mediaUploaded: function(type, file) {
if (type === 'banner') {
this.newBanner = file
} else {
this.newMedia = file
}
},
mediaRemoved: function(type) {
this.article[type] = null
this.data.article[type] = null
},
save: function(vnode, e) {
e.preventDefault()
if (!this.article.name) {
console.log(this.data)
let formData = new FormData()
if (this.newBanner) {
formData.append('banner', this.newBanner.file)
}
if (this.newMedia) {
formData.append('media', this.newMedia.file)
}
if (this.data.article.id) {
formData.append('id', this.data.article.id)
}
formData.append('admin_id', this.data.article.admin_id)
formData.append('name', this.data.article.name)
formData.append('content', this.data.article.content)
formData.append('is_featured', this.data.article.is_featured)
formData.append('path', this.data.article.path)
formData.append('page_id', this.data.article.page_id)
formData.append('publish_at', this.data.article.publish_at)
this.loading = true
common.sendRequest({
method: 'PUT',
url: '/api/auth/articles/' + this.lastid,
body: formData,
})
.then((result) => {
console.log('result', result)
}, (err) => {
this.error = err.message
})
.then(() => {
this.loading = false
m.redraw()
})
/*e.preventDefault()
if (!this.data.article.name) {
this.error = 'Name is missing'
} else if (!this.article.path) {
} else if (!this.data.article.path) {
this.error = 'Path is missing'
} else {
this.error = ''
}
if (this.error) return
this.article.description = vnode.state.froala && vnode.state.froala.html.get() || this.article.description
if (this.article.description) {
this.article.description = this.article.description.replace(/<p[^>]+data-f-id="pbf"[^>]+>[^>]+>[^>]+>[^>]+>/, '')
this.data.article.description = vnode.state.froala && vnode.state.froala.html.get() || this.data.article.description
if (this.data.article.description) {
this.data.article.description = this.data.article.description.replace(/<p[^>]+data-f-id="pbf"[^>]+>[^>]+>[^>]+>[^>]+>/, '')
}
this.loading = true
let promise
if (this.article.id) {
promise = Article.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,
published_at: new Date(this.article.published_at),
is_featured: this.article.is_featured,
staff_id: this.article.staff_id,
if (this.data.article.id) {
promise = Article.updateArticle(this.data.article.id, {
name: this.data.article.name,
path: this.data.article.path,
page_id: this.data.article.page_id,
description: this.data.article.description,
banner_id: this.data.article.banner && this.data.article.banner.id,
media_id: this.data.article.media && this.data.article.media.id,
publish_at: new Date(this.data.article.publish_at),
is_featured: this.data.article.is_featured,
staff_id: this.data.article.staff_id,
})
} else {
promise = Article.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,
published_at: new Date(this.article.published_at),
is_featured: this.article.is_featured,
staff_id: this.article.staff_id,
name: this.data.article.name,
path: this.data.article.path,
page_id: this.data.article.page_id,
description: this.data.article.description,
banner_id: this.data.article.banner && this.data.article.banner.id,
media_id: this.data.article.media && this.data.article.media.id,
publish_at: new Date(this.data.article.publish_at),
is_featured: this.data.article.is_featured,
staff_id: this.data.article.staff_id,
})
}
@ -209,86 +219,48 @@ const EditArticle = {
.then(function() {
vnode.state.loading = false
m.redraw()
})
})*/
},
uploadFile: function(vnode, event) {
if (!event.target.files[0]) return
vnode.state.error = ''
vnode.state.loadingFile = true
uploadFile: function(vnode, e) {
File.uploadFile(this.article.id, event.target.files[0])
.then(function(res) {
vnode.state.article.files.push(res)
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
event.target.value = null
vnode.state.loadingFile = false
m.redraw()
})
},
getFlatTree: function() {
let out = [{id: null, name: '-- Frontpage --'}]
Page.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
},
getStaffers: function() {
if (!this.article.staff_id) {
this.article.staff_id = 1
}
let out = []
this.staffers.forEach(function(item) {
out.push({ id: item.id, name: item.fullname })
})
return out
},
view: function(vnode) {
const showPublish = new Date(this.article.published_at) > new Date()
const parents = this.getFlatTree()
const staffers = this.getStaffers()
return (
const showPublish = this.data.article
? this.data.article.publish_at > new Date()
: false
return [
this.loading ?
m('div.loading-spinner')
: m('div.admin-wrapper', [
m('div.admin-actions', this.article.id
: null,
this.data.article
? m('div.admin-wrapper', [
m('div.admin-actions', this.data.article.id
? [
m('span', 'Actions:'),
m(m.route.Link, { href: '/article/' + this.article.path }, 'View article'),
m(m.route.Link, { href: '/article/' + this.data.article.path }, 'View article'),
]
: null),
m('article.editarticle', [
m('header', m('h1', this.creating ? 'Create Article' : 'Edit ' + (this.article.name || '(untitled)'))),
m('header', m('h1', this.creating ? 'Create Article' : 'Edit ' + (this.data.article.name || '(untitled)'))),
m('div.error', {
hidden: !this.error,
onclick: function() { vnode.state.error = '' },
onclick: () => { vnode.state.error = '' },
}, this.error),
m(FileUpload, {
height: 300,
onupload: this.mediaUploaded.bind(this, 'banner'),
onerror: function(e) { vnode.state.error = e },
onfile: this.mediaUploaded.bind(this, 'banner'),
ondelete: this.mediaRemoved.bind(this, 'banner'),
media: this.article && this.article.banner,
media: this.data.article && this.data.article.banner,
}),
m(FileUpload, {
class: 'cover',
useimg: true,
onupload: this.mediaUploaded.bind(this, 'media'),
onfile: this.mediaUploaded.bind(this, 'media'),
ondelete: this.mediaRemoved.bind(this, 'media'),
onerror: function(e) { vnode.state.error = e },
media: this.article && this.article.media,
media: this.data.article && this.data.article.media,
}),
m('form.editarticle.content', {
onsubmit: this.save.bind(this, vnode),
@ -296,27 +268,30 @@ const EditArticle = {
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) })),
}, this.pages.map((item) => {
return m('option', {
value: item.id || 0,
selected: item.id === this.data.article.page_id
}, item.name)
})),
m('label', 'Name'),
m('input', {
type: 'text',
value: this.article.name,
value: this.data.article.name,
oninput: this.updateValue.bind(this, 'name'),
}),
m('label.slim', 'Path'),
m('input.slim', {
type: 'text',
value: this.article.path,
value: this.data.article.path,
oninput: this.updateValue.bind(this, 'path'),
}),
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)
})
oncreate: (div) => {
console.log(div.dom)
},
})
: null
@ -324,37 +299,49 @@ const EditArticle = {
m('label', 'Published at'),
m('input', {
type: 'datetime-local',
value: this.article.published_at,
oninput: this.updateValue.bind(this, 'published_at'),
value: this.data.article.publish_at,
oninput: this.updateValue.bind(this, 'publish_at'),
}),
m('label', 'Published by'),
m('select', {
onchange: this.updateStaffer.bind(this),
}, staffers.map(function(item) { return m('option', { value: item.id, selected: item.id === vnode.state.article.staff_id }, item.name) })),
},
this.data.staff.map((item) => {
return m('option', {
value: item.id,
selected: item.id === this.data.article.staff_id
}, item.name)
})
),
m('label', 'Make featured'),
m('input', {
type: 'checkbox',
checked: this.article.is_featured,
checked: this.data.article.is_featured,
oninput: this.updateValue.bind(this, 'is_featured'),
}),
m('div.loading-spinner', { hidden: this.loadedFroala }),
m('div', [
m('input', {
type: 'submit',
value: 'Save',
}),
showPublish
? m('button.submit', { onclick: function() { vnode.state.article.published_at = new Date().toISOString() }}, 'Publish')
? m('button.submit', {
onclick: () => {
this.data.article.publish_at = new Date().toISOString()
}
}, 'Publish')
: null,
]),
]),
this.article.files.length
this.data.files.length
? m('files', [
m('h4', 'Files'),
this.article.files.map(function(item) { return m(Fileinfo, { file: item }) }),
this.data.files.map((file) => {
return m(Fileinfo, { file: file })
}),
])
: null,
this.article.id
this.data.article.id
? m('div.fileupload', [
'Add file',
m('input', {
@ -367,7 +354,8 @@ const EditArticle = {
: null,
]),
])
)
: null,
]
},
}

View file

@ -17,10 +17,10 @@ const EditStaff = {
this.creating = this.lastid === 'add'
this.error = ''
this.staff = {
fullname: '',
name: '',
email: '',
password: '',
level: 10,
rank: 10,
}
if (this.lastid !== 'add') {
@ -28,7 +28,7 @@ const EditStaff = {
.then(function(result) {
vnode.state.editedPath = true
vnode.state.staff = result
document.title = 'Editing: ' + result.fullname + ' - Admin NFP Moe'
document.title = 'Editing: ' + result.name + ' - Admin NFP Moe'
})
.catch(function(err) {
vnode.state.error = err.message
@ -42,13 +42,13 @@ const EditStaff = {
}
},
updateValue: function(fullname, e) {
this.staff[fullname] = e.currentTarget.value
updateValue: function(key, e) {
this.staff[key] = e.currentTarget.value
},
save: function(vnode, e) {
e.preventDefault()
if (!this.staff.fullname) {
if (!this.staff.name) {
this.error = 'Fullname is missing'
} else if (!this.staff.email) {
this.error = 'Email is missing'
@ -65,16 +65,16 @@ const EditStaff = {
if (this.staff.id) {
promise = Staff.updateStaff(this.staff.id, {
fullname: this.staff.fullname,
name: this.staff.name,
email: this.staff.email,
level: this.staff.level,
rank: this.staff.rank,
password: this.staff.password,
})
} else {
promise = Staff.createStaff({
fullname: this.staff.fullname,
name: this.staff.name,
email: this.staff.email,
level: this.staff.level,
rank: this.staff.rank,
password: this.staff.password,
})
}
@ -92,11 +92,11 @@ const EditStaff = {
},
updateLevel: function(e) {
this.staff.level = Number(e.currentTarget.value)
this.staff.rank = Number(e.currentTarget.value)
},
view: function(vnode) {
const levels = [[10, 'Manager'], [100, 'Admin']]
const ranks = [[10, 'Manager'], [100, 'Admin']]
return (
this.loading ?
m('div.loading-spinner')
@ -108,7 +108,7 @@ const EditStaff = {
]
: null),
m('article.editstaff', [
m('header', m('h1', this.creating ? 'Create Staff' : 'Edit ' + (this.staff.fullname || '(untitled)'))),
m('header', m('h1', this.creating ? 'Create Staff' : 'Edit ' + (this.staff.name || '(untitled)'))),
m('div.error', {
hidden: !this.error,
onclick: function() { vnode.state.error = '' },
@ -119,12 +119,12 @@ const EditStaff = {
m('label', 'Level'),
m('select', {
onchange: this.updateLevel.bind(this),
}, levels.map(function(level) { return m('option', { value: level[0], selected: level[0] === vnode.state.staff.level }, level[1]) })),
}, ranks.map(function(rank) { return m('option', { value: rank[0], selected: rank[0] === vnode.state.staff.rank }, rank[1]) })),
m('label', 'Fullname'),
m('input', {
type: 'text',
value: this.staff.fullname,
oninput: this.updateValue.bind(this, 'fullname'),
value: this.staff.name,
oninput: this.updateValue.bind(this, 'name'),
}),
m('label', 'Email'),
m('input', {

View file

@ -77,9 +77,9 @@ const AdminStaffList = {
),
m('tbody', this.staff.map(function(item) {
return m('tr', [
m('td', m(m.route.Link, { href: '/admin/staff/' + item.id }, item.fullname)),
m('td', m(m.route.Link, { href: '/admin/staff/' + item.id }, item.name)),
m('td', item.email),
m('td.right', AdminStaffList.getLevel(item.level)),
m('td.right', AdminStaffList.getLevel(item.rank)),
m('td.right', (item.updated_at || '---').replace('T', ' ').split('.')[0]),
m('td.right', m('button', { onclick: function() { vnode.state.removeStaff = item } }, 'Remove')),
])
@ -95,7 +95,7 @@ const AdminStaffList = {
m(Dialogue, {
hidden: vnode.state.removeStaff === null,
title: 'Delete ' + (vnode.state.removeStaff ? vnode.state.removeStaff.name : ''),
message: 'Are you sure you want to remove "' + (vnode.state.removeStaff ? vnode.state.removeStaff.fullname : '') + '" (' + (vnode.state.removeStaff ? vnode.state.removeStaff.email : '') + ')',
message: 'Are you sure you want to remove "' + (vnode.state.removeStaff ? vnode.state.removeStaff.name : '') + '" (' + (vnode.state.removeStaff ? vnode.state.removeStaff.email : '') + ')',
yes: 'Remove',
yesclass: 'alert',
no: 'Cancel',

View file

@ -1,46 +1,8 @@
const common = require('./common')
exports.getAllArticlesPagination = function(options) {
let extra = ''
if (options.sort) {
extra += '&sort=' + options.sort
}
if (options.per_page) {
extra += '&perPage=' + options.per_page
}
if (options.page) {
extra += '&page=' + options.page
}
if (options.includes) {
extra += '&includes=' + options.includes.join(',')
}
return '/api/articles/public?' + extra
}
exports.getAllPageArticlesPagination = function(pageId, options) {
let extra = ''
if (options.sort) {
extra += '&sort=' + options.sort
}
if (options.per_page) {
extra += '&perPage=' + options.per_page
}
if (options.page) {
extra += '&page=' + options.page
}
if (options.includes) {
extra += '&includes=' + options.includes.join(',')
}
return '/api/pages/' + pageId + '/articles/public?' + extra
}
exports.getArticle = function(id) {
return common.sendRequest({
method: 'GET',
url: '/api/articles/public/' + id + '?includes=media,parent,banner,files,staff',
url: '/api/articles/' + id,
})
}

View file

@ -41,7 +41,7 @@ exports.sendRequest = function(options, isPagination) {
return m.request(options)
.catch(function (error) {
if (error.code === 403) {
if (error.status === 403) {
Authentication.clearToken()
m.route.set('/login', { redirect: m.route.get() })
}

View file

@ -1,16 +1,23 @@
const common = require('./common')
const Tree = window.__nfptree && window.__nfptree.tree || []
const TreeMap = new Map()
exports.Tree = Tree
exports.TreeMap = TreeMap
exports.getTree = function() {
return common.sendRequest({
method: 'GET',
url: '/api/pagetree',
})
function parseLeaf(tree) {
for (let branch of tree) {
TreeMap.set(branch.path, branch)
if (branch.children && branch.children.length) {
parseLeaf(branch.children)
}
}
}
parseLeaf(Tree)
exports.getPage = function(path, page) {
return common.sendRequest({
method: 'GET',

View file

@ -73,6 +73,7 @@ main {
display: flex;
flex-direction: column;
flex-grow: 2;
position: relative;
}
.error {

View file

@ -6,68 +6,77 @@ const Fileinfo = require('../widgets/fileinfo')
const Article = {
oninit: function(vnode) {
this.error = ''
this.lastarticle = m.route.param('article') || '1'
this.loading = false
this.showLoading = null
this.data = {
article: null,
files: [],
}
this.showcomments = false
if (window.__nfpdata) {
this.path = m.route.param('id')
this.article = window.__nfpdata
this.data.article = window.__nfpdata
window.__nfpdata = null
} else {
this.fetchArticle(vnode)
}
},
fetchArticle: function(vnode) {
this.error = ''
this.path = m.route.param('id')
this.showcomments = false
this.article = {
id: 0,
name: '',
path: '',
description: '',
media: null,
banner: null,
files: [],
}
this.loading = true
ApiArticle.getArticle(this.path)
.then(function(result) {
vnode.state.article = result
if (result.parent) {
document.title = result.name + ' - ' + result.parent.name + ' - NFP Moe'
} else {
document.title = result.name + ' - NFP Moe'
}
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
vnode.state.loading = false
m.redraw()
})
},
onbeforeupdate: function(vnode) {
if (this.path !== m.route.param('id')) {
this.fetchArticle(vnode)
}
},
fetchArticle: function(vnode) {
this.error = ''
this.path = m.route.param('id')
this.showcomments = false
if (this.showLoading) {
clearTimeout(this.showLoading)
}
if (this.data.article) {
this.showLoading = setTimeout(() => {
this.showLoading = null
this.loading = true
m.redraw()
}, 150)
} else {
this.loading = true
}
ApiArticle.getArticle(this.path)
.then((result) => {
this.data = result
if (!this.data.article) {
this.error = 'Article not found'
}
}, (err) => {
this.error = err.message
})
.then(() => {
clearTimeout(this.showLoading)
this.showLoading = null
this.loading = false
m.redraw()
})
},
view: function(vnode) {
var deviceWidth = window.innerWidth
var imagePath = ''
if (this.article.media) {
if (this.data.article && this.data.article.media) {
var pixelRatio = window.devicePixelRatio || 1
if ((deviceWidth < 800 && pixelRatio <= 1)
|| (deviceWidth < 600 && pixelRatio > 1)) {
imagePath = this.article.media.medium_url
imagePath = this.data.article.media.medium_url
} else {
imagePath = this.article.media.large_url
imagePath = this.data.article.media.large_url
}
}
@ -82,33 +91,37 @@ const Article = {
},
}, 'Article error: ' + this.error))
: m('article.article', [
this.article.parent ? m('div.goback', ['« ', m(m.route.Link, { href: '/page/' + this.article.parent.path }, this.article.parent.name)]) : null,
m('header', m('h1', this.article.name)),
this.data.article.page_path
? m('div.goback', ['« ', m(m.route.Link, { href: '/page/' + this.data.article.page_path }, this.data.article.page_name)])
: null,
m('header', m('h1', this.data.article.name)),
m('.fr-view', [
this.article.media
this.data.article.media
? m('a.cover', {
rel: 'noopener',
href: this.article.media.link,
}, m('img', { src: imagePath, alt: 'Cover image for ' + this.article.name }))
href: this.data.article.media.link,
}, m('img', { src: imagePath, alt: 'Cover image for ' + this.data.article.name }))
: null,
this.article.description ? m.trust(this.article.description) : null,
(this.article.files && this.article.files.length
? this.article.files.map(function(file) {
this.data.article.content ? m.trust(this.data.article.content) : null,
this.data.files.map(function(file) {
return m(Fileinfo, { file: file })
})
: null),
}),
m('div.entrymeta', [
'Posted ',
(this.article.parent ? 'in' : ''),
(this.article.parent ? m(m.route.Link, { href: '/page/' + this.article.parent.path }, this.article.parent.name) : null),
'at ' + (this.article.published_at.replace('T', ' ').split('.')[0]).substr(0, 16),
' by ' + (this.article.staff && this.article.staff.fullname || 'Admin'),
this.data.article.page_path
? [
'in',
m(m.route.Link, { href: '/page/' + this.data.article.page_path }, this.data.article.page_name)
]
: '',
'at ' + (this.data.article.publish_at.replace('T', ' ').split('.')[0]).substr(0, 16),
' by ' + (this.data.article.admin_name || 'Admin'),
]),
]),
Authentication.currentUser
? m('div.admin-actions', [
m('span', 'Admin controls:'),
m(m.route.Link, { href: '/admin/articles/' + this.article.id }, 'Edit article'),
m(m.route.Link, { href: '/admin/articles/' + this.data.article.path }, 'Edit article'),
])
: null,
this.showcomments

View file

@ -10,92 +10,87 @@ const Frontpage = {
oninit: function(vnode) {
this.error = ''
this.loading = false
this.featured = null
this.links = null
if (window.__nfpfeatured) {
this.featured = window.__nfpfeatured
this.showLoading = null
this.data = {
page: null,
articles: [],
total_articles: 0,
featured: null,
}
this.currentPage = Number(m.route.param('page')) || 1
if (window.__nfpdata
&& window.__nfplinks) {
this.links = window.__nfplinks
this.articles = window.__nfpdata
this.lastpage = m.route.param('page') || '1'
if (window.__nfpdata) {
this.lastpage = this.currentPage
window.__nfpdata = null
window.__nfplinks = null
if (this.articles.length === 0) {
m.route.set('/')
} else {
Frontpage.processFeatured(vnode, this.articles)
}
} else {
this.fetchArticles(vnode)
this.fetchPage(vnode)
}
},
onupdate: function(vnode) {
if (this.lastpage !== (m.route.param('page') || '1')) {
this.fetchArticles(vnode)
m.redraw()
onbeforeupdate: function(vnode) {
this.currentPage = Number(m.route.param('page')) || 1
if (this.lastpage !== this.currentPage) {
this.fetchPage(vnode)
}
},
fetchArticles: function(vnode) {
fetchPage: function(vnode) {
this.error = ''
this.loading = true
this.links = null
this.articles = []
this.lastpage = m.route.param('page') || '1'
this.lastpage = this.currentPage
if (this.lastpage !== '1') {
if (this.showLoading) {
clearTimeout(this.showLoading)
}
this.showLoading = setTimeout(() => {
this.showLoading = null
this.loading = true
m.redraw()
}, 150)
if (this.lastpage !== 1) {
document.title = 'Page ' + this.lastpage + ' - NFP Moe - Anime/Manga translation group'
} else {
document.title = 'NFP Moe - Anime/Manga translation group'
}
return Page.getPage(null, this.lastpage)
.then(function(result) {
console.log(result)
.then((result) => {
this.data = result
}, (err) => {
this.error = err.message
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
vnode.state.loading = false
.then(() => {
clearTimeout(this.showLoading)
this.showLoading = null
this.loading = false
m.redraw()
})
},
processFeatured: function(vnode, data) {
if (vnode.state.featured) return
for (var i = data.length - 1; i >= 0; i--) {
if (data[i].banner) {
vnode.state.featured = data[i]
}
}
},
view: function(vnode) {
var deviceWidth = window.innerWidth
var bannerPath = ''
if (this.featured && this.featured.banner) {
if (this.data.featured && this.data.featured.banner) {
var pixelRatio = window.devicePixelRatio || 1
if (deviceWidth < 400 && pixelRatio <= 1) {
bannerPath = window.supportsavif
&& this.featured.banner.small_url_avif
|| this.featured.banner.small_url
&& this.data.featured.banner.small_url_avif
|| this.data.featured.banner.small_url
} else if ((deviceWidth < 800 && pixelRatio <= 1)
|| (deviceWidth < 600 && pixelRatio > 1)) {
bannerPath = window.supportsavif
&& this.featured.banner.medium_url_avif
|| this.featured.banner.medium_url
&& this.data.featured.banner.medium_url_avif
|| this.data.featured.banner.medium_url
} else {
bannerPath = window.supportsavif
&& this.featured.banner.large_url_avif
|| this.featured.banner.large_url
&& this.data.featured.banner.large_url_avif
|| this.data.featured.banner.large_url
}
}
@ -103,10 +98,10 @@ const Frontpage = {
(bannerPath
? m(m.route.Link, {
class: 'frontpage-banner',
href: '/article/' + this.featured.path,
href: '/article/' + this.data.featured.path,
style: { 'background-image': 'url("' + bannerPath + '")' },
},
this.featured.name
this.data.featured.name
)
: null),
m('frontpage', [
@ -134,12 +129,13 @@ const Frontpage = {
(this.loading
? m('div.loading-spinner')
: null),
this.articles.map(function(article) {
return m(Newsitem, article)
this.data.articles.map(function(article) {
return m(Newsitem, { article: article })
}),
m(Pages, {
base: '/',
links: this.links,
total: this.data.total_articles,
page: this.currentPage,
}),
]),
]),

View file

@ -34,12 +34,13 @@ const onLoaded = function() {
loaded++
if (loaded < 2) return
Authentication.setAdmin(Authentication.currentUser && Authentication.currentUser.level >= 10)
Authentication.setAdmin(Authentication.currentUser && Authentication.currentUser.rank >= 10)
loadedAdmin = true
m.route.set(m.route.get())
}
const onError = function() {
const onError = function(a, b, c) {
console.log('onError', this, a, b, c)
elements.forEach(function(x) { x.remove() })
loadedAdmin = loadingAdmin = false
loaded = 0
@ -49,11 +50,11 @@ const onError = function() {
const loadAdmin = function(user) {
if (loadingAdmin) {
if (loadedAdmin) {
Authentication.setAdmin(user && user.level >= 10)
Authentication.setAdmin(user && user.rank >= 10)
}
return
}
if (!user || user.level < 10) return
if (!user || user.rank < 10) return
loadingAdmin = true
@ -74,6 +75,14 @@ const loadAdmin = function(user) {
element.onload = onLoaded
element.onerror = onError
document.body.appendChild(element)
element = document.createElement('script')
elements.push(element)
element.setAttribute('type', 'text/javascript')
element.setAttribute('src', '/assets/editor.js')
element.onload = onLoaded
element.onerror = onError
document.body.appendChild(element)
}
Authentication.addEvent(loadAdmin)

View file

@ -35,13 +35,17 @@ const Login = {
Api.sendRequest({
method: 'POST',
url: '/api/login/user',
url: '/api/authentication/login',
body: {
username: this.username,
email: this.username,
password: this.password,
},
})
.then(function(result) {
if (!result.token) {
return Promise.reject(new Error('Server authentication down.'))
}
console.log(result)
Authentication.updateToken(result.token)
m.route.set(Login.redirect || '/')
})

View file

@ -37,7 +37,6 @@ const Menu = {
},
view: function() {
console.log(Page.Tree)
return [
m('div.top', [
m(m.route.Link,
@ -46,7 +45,7 @@ const Menu = {
),
m('aside', Authentication.currentUser ? [
m('p', [
'Welcome ' + Authentication.currentUser.email,
'Welcome ' + Authentication.currentUser.name,
m(m.route.Link, { href: '/logout' }, 'Logout'),
(Darkmode.darkIsOn
? m('button.dark', { onclick: Darkmode.setDarkMode.bind(Darkmode, false) }, 'Day mode')
@ -58,9 +57,9 @@ const Menu = {
m(m.route.Link, { href: '/admin/articles/add' }, 'Create article'),
m(m.route.Link, { href: '/admin/articles' }, 'Articles'),
m(m.route.Link, { href: '/admin/pages' }, 'Pages'),
m(m.route.Link, { hidden: Authentication.currentUser.level < 100, href: '/admin/staff' }, 'Staff'),
m(m.route.Link, { hidden: Authentication.currentUser.rank < 100, href: '/admin/staff' }, 'Staff'),
])
: (Authentication.currentUser.level > 10 ? m('div.loading-spinner') : null)
: (Authentication.currentUser.rank > 10 ? m('div.loading-spinner') : null)
),
] : (Darkmode.darkIsOn
? m('button.dark', { onclick: Darkmode.setDarkMode.bind(Darkmode, false) }, 'Day mode')

View file

@ -9,15 +9,20 @@ const Pages = require('../widgets/pages')
const Page = {
oninit: function(vnode) {
this.error = ''
this.lastpage = m.route.param('page') || '1'
this.loadingnews = false
this.loading = false
this.showLoading = null
this.data = {
page: null,
articles: [],
total_articles: 0,
featured: null,
}
this.children = []
this.currentPage = Number(m.route.param('page')) || 1
console.log(window.__nfpdata)
if (window.__nfpdata) {
this.path = m.route.param('id')
this.page = window.__nfpdata
this.news = window.__nfpsubdata
this.newslinks = window.__nfplinks
this.data = window.__nfpdata
window.__nfpdata = null
window.__nfpsubdata = null
@ -26,60 +31,57 @@ const Page = {
}
},
fetchPage: function(vnode) {
this.path = m.route.param('id')
this.news = []
this.newslinks = null
this.page = {
id: 0,
name: '',
path: '',
description: '',
media: null,
}
this.loading = true
this.loadingnews = true
ApiPage.getPage(this.path)
.then(function(result) {
vnode.state.page = result
document.title = result.name + ' - NFP Moe'
return vnode.state.fetchArticles(vnode)
})
.catch(function(err) {
vnode.state.error = err.message
vnode.state.loading = vnode.state.loadingnews = false
m.redraw()
})
},
onbeforeupdate: function(vnode) {
if (this.path !== m.route.param('id')) {
this.currentPage = Number(m.route.param('page')) || 1
if (this.path !== m.route.param('id') || this.currentPage !== this.lastpage) {
this.fetchPage(vnode)
} else if (m.route.param('page') && m.route.param('page') !== this.lastpage) {
this.fetchArticles(vnode)
}
},
fetchArticles: function(vnode) {
this.loadingnews = true
this.newslinks = null
this.lastpage = m.route.param('page') || '1'
fetchPage: function(vnode) {
this.error = ''
this.lastpage = this.currentPage
this.path = m.route.param('id')
return pagination.fetchPage(Article.getAllPageArticlesPagination(this.page.id, {
per_page: 10,
page: this.lastpage,
includes: ['files', 'media'],
}))
.then(function(result) {
vnode.state.news = result.data
vnode.state.newslinks = result.links
if (this.showLoading) {
clearTimeout(this.showLoading)
}
if (this.data.page) {
this.showLoading = setTimeout(() => {
this.showLoading = null
this.loading = true
m.redraw()
}, 150)
} else {
this.loading = true
}
this.children = ApiPage.TreeMap.get(this.path)
this.children = this.children && this.children.children || []
ApiPage.getPage(this.path, this.lastpage)
.then((result) => {
this.data = result
if (!this.data.page) {
this.error = 'Page not found'
return
}
if (this.lastpage !== 1) {
document.title = 'Page ' + this.lastpage + ' - ' + this.data.page.name + ' - NFP Moe'
} else {
document.title = this.data.page.name + ' - NFP Moe'
}
}, (err) => {
this.error = err.message
})
.catch(function(err) {
vnode.state.error = err.message
})
.then(function() {
vnode.state.loading = vnode.state.loadingnews = false
.then(() => {
clearTimeout(this.showLoading)
this.showLoading = null
this.loading = false
m.redraw()
})
},
@ -90,94 +92,106 @@ const Page = {
var bannerPath = ''
var imagePath = ''
if (this.page && this.page.banner) {
if (this.data.page && this.data.page.banner) {
if (deviceWidth < 400 && pixelRatio <= 1) {
bannerPath = this.page.banner.small_url
bannerPath = this.data.page.banner.small_url
} else if ((deviceWidth < 800 && pixelRatio <= 1)
|| (deviceWidth < 600 && pixelRatio > 1)) {
bannerPath = this.page.banner.medium_url
bannerPath = this.data.page.banner.medium_url
} else {
bannerPath = this.page.banner.large_url
bannerPath = this.data.page.banner.large_url
}
}
if (this.page && this.page.media) {
if (this.data.page && this.data.page.media) {
if ((deviceWidth < 1000 && pixelRatio <= 1)
|| (deviceWidth < 800 && pixelRatio > 1)) {
imagePath = this.page.media.medium_url
imagePath = this.data.page.media.medium_url
} else {
imagePath = this.page.media.large_url
imagePath = this.data.page.media.large_url
}
}
return (
this.loading ?
m('article.page', m('div.loading-spinner'))
: this.error
return ([
this.loading
? m('article.page', m('div.loading-spinner'))
: null,
!this.loading && this.error
? m('div.error-wrapper', m('div.error', {
onclick: function() {
vnode.state.error = ''
vnode.state.fetchPage(vnode)
},
}, 'Article error: ' + this.error))
: m('article.page', [
bannerPath ? m('.div.page-banner', { style: { 'background-image': 'url("' + bannerPath + '")' } } ) : null,
this.page.parent
? m('div.goback', ['« ', m(m.route.Link, { href: '/page/' + this.page.parent.path }, this.page.parent.name)])
: m('div.goback', ['« ', m(m.route.Link, { href: '/' }, 'Home')]),
m('header', m('h1', this.page.name)),
}, 'Page error: ' + this.error))
: null,
!this.loading && !this.error
? m('article.page', [
bannerPath
? m('.div.page-banner', { style: { 'background-image': 'url("' + bannerPath + '")' } } )
: null,
m('div.goback', ['« ', m(m.route.Link, {
href: this.data.page.parent_path
? '/page/' + this.data.page.parent_path
: '/'
}, this.data.page.parent_name || 'Home')]),
m('header', m('h1', this.data.page.name)),
m('.container', {
class: this.page.children.length ? 'multi' : '',
class: this.children.length ? 'multi' : '',
}, [
this.page.children.length
this.children.length
? m('aside.sidebar', [
m('h4', 'View ' + this.page.name + ':'),
this.page.children.map(function(page) {
m('h4', 'View ' + this.data.page.name + ':'),
this.children.map(function(page) {
return m(m.route.Link, { href: '/page/' + page.path }, page.name)
}),
])
: null,
this.page.description
this.data.page.content
? m('.fr-view', [
imagePath ? m('a', { href: this.page.media.link}, m('img.page-cover', { src: imagePath, alt: 'Cover image for ' + this.page.name } )) : null,
m.trust(this.page.description),
this.news.length && this.page.description
imagePath
? m('a', { href: this.data.page.media.link}, m('img.page-cover', { src: imagePath, alt: 'Cover image for ' + this.data.page.name } ))
: null,
m.trust(this.data.page.content),
this.data.articles.length && this.data.page.content
? m('aside.news', [
m('h4', 'Latest posts under ' + this.page.name + ':'),
this.loadingnews ? m('div.loading-spinner') : this.news.map(function(article) {
m('h4', 'Latest posts under ' + this.data.page.name + ':'),
this.data.articles.map(function(article) {
return m(Newsentry, article)
}),
m(Pages, {
base: '/page/' + this.page.path,
links: this.newslinks,
base: '/page/' + this.data.page.path,
total: this.data.total_articles,
page: this.currentPage,
}),
])
: null,
])
: this.news.length
: this.data.articles.length
? m('aside.news.single', [
imagePath ? m('a', { href: this.page.media.link}, m('img.page-cover', { src: imagePath, alt: 'Cover image for ' + this.page.name } )) : null,
m('h4', 'Latest posts under ' + this.page.name + ':'),
this.loadingnews ? m('div.loading-spinner') : this.news.map(function(article) {
imagePath ? m('a', { href: this.data.page.media.link}, m('img.page-cover', { src: imagePath, alt: 'Cover image for ' + this.data.page.name } )) : null,
m('h4', 'Latest posts under ' + this.data.page.name + ':'),
this.data.articles.map(function(article) {
return m(Newsentry, article)
}),
m(Pages, {
base: '/page/' + this.page.path,
links: this.newslinks,
base: '/page/' + this.data.page.path,
total: this.data.total_articles,
page: this.currentPage,
}),
])
: this.page.media
? m('img.page-cover.single', { src: this.page.media.medium_url, alt: 'Cover image for ' + this.page.name } )
: this.data.page.media
? m('img.page-cover.single', { src: this.data.page.media.medium_url, alt: 'Cover image for ' + this.data.page.name } )
: null,
]),
Authentication.currentUser
? m('div.admin-actions', [
m('span', 'Admin controls:'),
m(m.route.Link, { href: '/admin/pages/' + this.page.id }, 'Edit page'),
m(m.route.Link, { href: '/admin/pages/' + this.data.page.path }, 'Edit page'),
])
: null,
])
: null,
])
)
},
}

View file

@ -1,38 +1,32 @@
const Media = require('../api/media')
const FileUpload = {
uploadFile: function(vnode, event) {
fileChanged: function(vnode, event) {
if (!event.target.files[0]) return
vnode.state.updateError(vnode, '')
vnode.state.loading = true
Media.uploadMedia(event.target.files[0], vnode.attrs.height || null)
.then(function(res) {
if (vnode.attrs.onupload) {
vnode.attrs.onupload(res)
let preview = null
if (event.target.files[0].type.startsWith('image')) {
preview = URL.createObjectURL(event.target.files[0])
}
if (this.preview) {
this.preview.clear()
}
})
.catch(function(err) {
vnode.state.updateError(vnode, err.message)
})
.then(function() {
event.target.value = null
vnode.state.loading = false
m.redraw()
})
},
updateError: function(vnode, error) {
if (vnode.attrs.onerror) {
vnode.attrs.onerror(error)
} else {
vnode.state.error = error
let out = {
file: event.target.files[0],
preview: preview,
clear: function() {
URL.revokeObjectURL(preview)
}
}
this.preview = out
vnode.attrs.onfile(out)
},
oninit: function(vnode) {
vnode.state.loading = false
vnode.state.error = ''
this.loading = false
this.preview = null
},
view: function(vnode) {
@ -41,27 +35,28 @@ const FileUpload = {
return m('fileupload', {
class: vnode.attrs.class || null,
}, [
m('div.error', {
hidden: !vnode.state.error,
}, vnode.state.error),
(media
this.preview || media
? vnode.attrs.useimg
? [ m('img', { src: media.large_url }), m('div.showicon')]
? [ m('img', { src: this.preview && this.preview.preview || media.large_url }), m('div.showicon')]
: m('a.display.inside', {
href: media.large_url,
href: this.preview && this.preview.preview || media.large_url,
style: {
'background-image': 'url("' + media.large_url + '")',
'background-image': 'url("' + (this.preview && this.preview.preview || media.large_url) + '")',
},
}, m('div.showicon'))
: m('div.inside.showbordericon')
),
,
m('input', {
accept: 'image/*',
type: 'file',
onchange: this.uploadFile.bind(this, vnode),
onchange: this.fileChanged.bind(this, vnode),
}),
(media && vnode.attrs.ondelete ? m('button.remove', { onclick: vnode.attrs.ondelete }) : null),
(vnode.state.loading ? m('div.loading-spinner') : null),
media && vnode.attrs.ondelete
? m('button.remove', { onclick: vnode.attrs.ondelete })
: null,
this.loading
? m('div.loading-spinner')
: null,
])
},
}

View file

@ -2,12 +2,12 @@ const Fileinfo = require('./fileinfo')
const Newsitem = {
oninit: function(vnode) {
if (vnode.attrs.media) {
this.srcsetJpeg = vnode.attrs.media.small_url + ' 500w, '
+ vnode.attrs.media.medium_url + ' 800w '
if (vnode.attrs.media.small_url_avif) {
this.srcsetAvif = vnode.attrs.media.small_url_avif + ' 500w, '
+ vnode.attrs.media.medium_url_avif + ' 800w '
if (vnode.attrs.article.media) {
this.srcsetJpeg = vnode.attrs.article.media.small_url + ' 500w, '
+ vnode.attrs.article.media.medium_url + ' 800w '
if (vnode.attrs.article.media.small_url_avif) {
this.srcsetAvif = vnode.attrs.article.media.small_url_avif + ' 500w, '
+ vnode.attrs.article.media.medium_url_avif + ' 800w '
} else {
this.srcsetAvif = null
}
@ -20,14 +20,14 @@ const Newsitem = {
view: function(vnode) {
return m('newsitem', [
m(m.route.Link,
{ href: '/article/' + vnode.attrs.path, class: 'title' },
m('h3', [vnode.attrs.name])
{ href: '/article/' + vnode.attrs.article.path, class: 'title' },
m('h3', [vnode.attrs.article.name])
),
m('div.newsitemcontent', [
vnode.attrs.media
vnode.attrs.article.media
? m(m.route.Link, {
class: 'cover',
href: '/article/' + vnode.attrs.path,
href: '/article/' + vnode.attrs.article.path,
},
m('picture', [
this.srcsetAvif ? m('source', {
@ -38,28 +38,28 @@ const Newsitem = {
m('img', {
srcset: this.srcsetJpeg,
sizes: this.coverSizes,
alt: 'Image for news item ' + vnode.attrs.name,
src: vnode.attrs.media.small_url }),
alt: 'Image for news item ' + vnode.attrs.article.name,
src: vnode.attrs.article.media.small_url }),
])
)
: null,
m('div.entrycontent', {
class: vnode.attrs.media ? '' : 'extrapadding',
class: vnode.attrs.article.media ? '' : 'extrapadding',
}, [
(vnode.attrs.description
? m('.fr-view', m.trust(vnode.attrs.description))
(vnode.attrs.article.content
? m('.fr-view', m.trust(vnode.attrs.article.content))
: null),
(vnode.attrs.files && vnode.attrs.files.length
? vnode.attrs.files.map(function(file) {
(vnode.attrs.article.files && vnode.attrs.article.files.length
? vnode.attrs.article.files.map(function(file) {
return m(Fileinfo, { file: file, trim: true })
})
: null),
m('span.entrymeta', [
'Posted ',
(vnode.attrs.parent ? 'in' : ''),
(vnode.attrs.parent ? m(m.route.Link, { href: '/page/' + vnode.attrs.parent.path }, vnode.attrs.parent.name) : null),
'at ' + (vnode.attrs.published_at.replace('T', ' ').split('.')[0]).substr(0, 16),
' by ' + (vnode.attrs.staff && vnode.attrs.staff.fullname || 'Admin'),
(vnode.attrs.article.page_path ? 'in' : ''),
(vnode.attrs.article.page_path ? m(m.route.Link, { href: '/page/' + vnode.attrs.article.page_path }, vnode.attrs.article.page_name) : null),
'at ' + (vnode.attrs.article.publish_at.replace('T', ' ').split('.')[0]).substr(0, 16),
' by ' + (vnode.attrs.article.admin_name || 'Admin'),
]),
]),
]),

View file

@ -1,35 +1,41 @@
const Pages = {
oninit: function(vnode) {
this.onpage = vnode.attrs.onpage || function() {}
this.onbeforeupdate(vnode)
},
onbeforeupdate: function(vnode) {
this.total = vnode.attrs.total
this.currentPage = vnode.attrs.page
this.perPage = vnode.attrs.perPage || 10
this.maxPage = this.total / this.perPage + 1
},
view: function(vnode) {
if (!vnode.attrs.links) return null
if (this.total <= this.perPage) return null
return m('pages', [
vnode.attrs.links.first
? m(m.route.Link, {
href: vnode.attrs.base + '?page=' + vnode.attrs.links.first.page,
onclick: function() { vnode.state.onpage(vnode.attrs.links.first.page) },
}, 'First')
this.currentPage > 1
? [
m(m.route.Link, {
href: vnode.attrs.base,
}, 'First'),
m(m.route.Link, {
href: vnode.attrs.base + (this.currentPage > 2
? '?page=' + (this.currentPage - 1)
: ''
),
}, 'Previous'),
]
: m('div'),
vnode.attrs.links.previous
? m(m.route.Link, {
href: vnode.attrs.base + '?page=' + vnode.attrs.links.previous.page,
onclick: function() { vnode.state.onpage(vnode.attrs.links.previous.page) },
}, vnode.attrs.links.previous.title)
: m('div'),
m('div', vnode.attrs.links.current && vnode.attrs.links.current.title || 'Current page'),
vnode.attrs.links.next
? m(m.route.Link, {
href: vnode.attrs.base + '?page=' + vnode.attrs.links.next.page,
onclick: function() { vnode.state.onpage(vnode.attrs.links.next.page) },
}, vnode.attrs.links.next.title)
: m('div'),
vnode.attrs.links.last
? m(m.route.Link, {
href: vnode.attrs.base + '?page=' + vnode.attrs.links.last.page,
onclick: function() { vnode.state.onpage(vnode.attrs.links.last.page) },
m('div', 'Page ' + this.currentPage),
this.currentPage < this.maxPage
? [
m(m.route.Link, {
href: vnode.attrs.base + '?page=' + (this.currentPage + 1),
}, 'Next'),
m(m.route.Link, {
href: vnode.attrs.base + '?page=' + this.maxPage,
}, 'Last')
]
: m('div'),
])
},

View file

@ -49,8 +49,9 @@
"dependencies": {
"bencode": "^2.0.3",
"dot": "^2.0.0-beta.1",
"flaska": "^1.2.5",
"flaska": "^1.3.0",
"format-link-header": "^2.1.0",
"formidable": "^1.2.6",
"msnodesqlv8": "^2.4.7",
"nconf-lite": "^1.0.1",
"striptags": "^3.1.1"

2
public/assets/editor.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -33,6 +33,6 @@
<main id="main"></main>
<footer id="footer"></footer>
</div>
<script type="text/javascript" src="/assets/app.js?v={{=version}}" nonce="{{=nonce}}"></script>
<script type="text/javascript" src="/assets/app.js?v={{=version}}"></script>
</body>
</html>