diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..03feabf --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,44 @@ +version: 2 +jobs: + build: + docker: + - image: docker:latest + environment: + - di: "nfpis/nfpmoe" + - dtag: "latest" + - service_name: "nfpmoe" + - target_port: "7030" # The public port + - service_port: "4030" # Container port + working_directory: ~/nfpmoe + steps: + - run: + name: Update and install SSH & Git + command: apk update && apk upgrade && apk add --no-cache bash git openssh + - checkout + - setup_remote_docker + - run: + name: Build docker image + command: docker build -t ${di}:build_${CIRCLE_BUILD_NUM} -t ${di}:${CIRCLE_SHA1} -t ${di}:${dtag} . + - run: + name: Push to docker + command: | + docker login -u $DOCKER_USER -p $DOCKER_PASS + docker push ${di} + - deploy: + name: Deploy to production + command: | + if [ "${CIRCLE_BRANCH}" != "master" ]; then + echo Not running on master. Exiting. + exit 0 + fi + echo "$MASTER_HOST" | base64 -d > ~/.ssh/master_host + echo "$MASTER_KEY" | base64 -d > ~/.ssh/master_key + chmod 600 ~/.ssh/master_key + ssh -p 51120 -i ~/.ssh/master_key -o "UserKnownHostsFile ~/.ssh/master_host" root@82.221.107.21 "docker ${service_name} ${di}:${CIRCLE_SHA1} ${target_port} ${service_port}" + +workflows: + version: 2 + build_deploy: + jobs: + - build: + context: org-global diff --git a/.eslintrc b/.eslintrc index 6a1e87f..190386e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,11 +6,15 @@ "impliedStrict": true } }, + "globals": { + "FroalaEditor": "readonly" + }, "extends": "eslint:recommended", "env": { "mocha": true, "node": true, - "es6": true + "es6": true, + "browser": true }, "rules": { "require-await": 0, @@ -48,7 +52,7 @@ "no-sync": 2, "array-bracket-newline": [2, "consistent"], "block-spacing": [2, "always"], - "brace-style": [2, "1tbs"], + "brace-style": [2, "1tbs", { "allowSingleLine": true }], "comma-dangle": [2, "always-multiline"], "comma-spacing": 2, "comma-style": 2, @@ -66,7 +70,7 @@ 2, { "args": "after-used", - "argsIgnorePattern": "next|res|req" + "argsIgnorePattern": "next|res|req|vnode" } ], "generator-star-spacing": 0, diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b002cfe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +########################### +# Angular +########################### +FROM node:alpine as build + +ENV HOME=/app + +COPY package.json $HOME/ +COPY app $HOME/app +COPY public $HOME/public + +WORKDIR $HOME + +RUN apk add --update --no-cache --virtual .build-deps gcc g++ make libc6-compat python && \ + apk add build-base --no-cache \ + --repository https://alpine.global.ssl.fastly.net/alpine/edge/main \ + --repository https://alpine.global.ssl.fastly.net/alpine/edge/testing \ + --repository https://alpine.global.ssl.fastly.net/alpine/edge/community && \ + npm install && \ + apk del .build-deps gcc g++ make libc6-compat python && \ + apk del build-base && \ + npm run build + +########################### +# Server +########################### +FROM node:alpine + +ENV HOME=/app + +COPY index.mjs package.json server.mjs $HOME/ + +WORKDIR $HOME + +RUN apk add --update --no-cache --virtual .build-deps gcc g++ make libc6-compat python && \ + apk add vips-dev fftw-dev build-base --no-cache \ + --repository https://alpine.global.ssl.fastly.net/alpine/edge/main \ + --repository https://alpine.global.ssl.fastly.net/alpine/edge/testing \ + --repository https://alpine.global.ssl.fastly.net/alpine/edge/community && \ + npm install --production && \ + rm -Rf $HOME/.npm $HOME/.npm $HOME/.config && \ + apk del .build-deps gcc g++ make libc6-compat python && \ + apk del build-base + +COPY api $HOME/api +COPY migrations $HOME/migrations +COPY config $HOME/config +COPY --from=build /app/public $HOME/public + +EXPOSE 4030 + +CMD ["npm", "start"] diff --git a/api/access/middleware.mjs b/api/access/middleware.mjs index 055d715..157d266 100644 --- a/api/access/middleware.mjs +++ b/api/access/middleware.mjs @@ -1,4 +1,4 @@ -import orgAccess from './index' +import orgAccess from './index.mjs' export function accessChecks(opts = { }) { const access = opts.access || orgAccess diff --git a/api/article/model.mjs b/api/article/model.mjs index e051820..fdbe406 100644 --- a/api/article/model.mjs +++ b/api/article/model.mjs @@ -1,5 +1,6 @@ import bookshelf from '../bookshelf.mjs' import Media from '../media/model.mjs' +import File from '../file/model.mjs' import Staff from '../staff/model.mjs' import Page from '../page/model.mjs' @@ -38,6 +39,13 @@ const Article = bookshelf.createModel({ staff() { return this.belongsTo(Staff, 'staff_id') }, + + files() { + return this.hasManyFiltered(File, 'file', 'article_id') + .query(qb => { + qb.orderBy('id', 'asc') + }) + }, }, { getSingle(id, withRelated = [], require = true, ctx = null) { return this.query(qb => { @@ -46,6 +54,28 @@ const Article = bookshelf.createModel({ }) .fetch({ require, withRelated, ctx }) }, + + getAllFromPage(ctx, pageId, withRelated = [], orderBy = 'id') { + return this.query(qb => { + this.baseQueryAll(ctx, qb, {}, orderBy) + qb.leftOuterJoin('pages', 'articles.parent_id', 'pages.id') + qb.where(subq => { + subq.where('pages.id', pageId) + .orWhere('pages.parent_id', pageId) + }) + qb.select('articles.*') + }) + .fetchPage({ + pageSize: ctx.state.pagination.perPage, + page: ctx.state.pagination.page, + withRelated, + ctx: ctx, + }) + .then(result => { + ctx.state.pagination.total = result.pagination.rowCount + return result + }) + }, }) export default Article diff --git a/api/article/routes.mjs b/api/article/routes.mjs index 7015db5..596569c 100644 --- a/api/article/routes.mjs +++ b/api/article/routes.mjs @@ -13,12 +13,14 @@ export default class ArticleRoutes { 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, { }, ctx.state.filter.includes) + } - ctx.body = await this.Article.getAll(ctx, filter, ctx.state.filter.includes) + /** GET: /api/pages/:pageId/articles */ + async getAllPageArticles(ctx) { + await this.security.ensureIncludes(ctx) + + ctx.body = await this.Article.getAllFromPage(ctx, ctx.params.pageId, ctx.state.filter.includes, ctx.query.sort || '-id') } /** GET: /api/articles/:id */ diff --git a/api/article/security.mjs b/api/article/security.mjs index 2e81824..ceb8deb 100644 --- a/api/article/security.mjs +++ b/api/article/security.mjs @@ -15,7 +15,7 @@ const validFields = [ ] export async function ensureIncludes(ctx) { - let out = filter(ctx.state.filter.includes, ['staff', 'media', 'parent', 'banner']) + let out = filter(ctx.state.filter.includes, ['staff', 'media', 'parent', 'banner', 'files']) if (out.length > 0) { ctx.throw(422, `Includes had following invalid values: ${out.join(', ')}`) diff --git a/api/authentication/google.mjs b/api/authentication/google.mjs index aa7404d..2550154 100644 --- a/api/authentication/google.mjs +++ b/api/authentication/google.mjs @@ -1,6 +1,5 @@ -import google from 'googleapis' import googleauth from 'google-auth-library' -import config from '../config' +import config from '../config.mjs' const oauth2Client = new googleauth.OAuth2Client(config.get('googleid')) @@ -11,4 +10,3 @@ const oauth2Client = new googleauth.OAuth2Client(config.get('googleid')) export function getProfile(token) { return oauth2Client.getTokenInfo(token) } - \ No newline at end of file diff --git a/api/authentication/routes.mjs b/api/authentication/routes.mjs index 41ea90f..6e9716f 100644 --- a/api/authentication/routes.mjs +++ b/api/authentication/routes.mjs @@ -1,6 +1,6 @@ -import Staff from '../staff/model' -import Jwt from '../jwt' -import * as google from './google' +import Staff from '../staff/model.mjs' +import Jwt from '../jwt.mjs' +import * as google from './google.mjs' export default class AuthRoutes { constructor(opts = {}) { diff --git a/api/bookshelf.mjs b/api/bookshelf.mjs index 43915f2..f041589 100644 --- a/api/bookshelf.mjs +++ b/api/bookshelf.mjs @@ -2,9 +2,9 @@ import _ from 'lodash' import knex from 'knex' import bookshelf from 'bookshelf' -import config from './config' -import defaults from './defaults' -import log from './log' +import config from './config.mjs' +import defaults from './defaults.mjs' +import log from './log.mjs' let connections = [config.get('knex:connection')] diff --git a/api/config.mjs b/api/config.mjs index f4a8a53..4aeaa7a 100644 --- a/api/config.mjs +++ b/api/config.mjs @@ -25,6 +25,15 @@ nconf.env({ 'knex__connection__user', 'knex__connection__database', 'knex__connection__password', + 'knex__connectionslave__host', + 'knex__connectionslave__user', + 'knex__connectionslave__database', + 'knex__connectionslave__password', + 'upload__baseurl', + 'upload__port', + 'upload__host', + 'upload__name', + 'upload__secret', 'bunyan__name', 'frontend__url', 'jwt__secret', diff --git a/api/file/model.mjs b/api/file/model.mjs index 3c884cf..3b266a9 100644 --- a/api/file/model.mjs +++ b/api/file/model.mjs @@ -1,5 +1,5 @@ -import path from 'path' -import bookshelf from '../bookshelf' +import bookshelf from '../bookshelf.mjs' +import config from '../config.mjs' /* @@ -8,19 +8,38 @@ File model: filename, filetype, size, + path, staff_id, article_id, is_deleted, created_at, updated_at, + *url, + *magnet, } */ const File = bookshelf.createModel({ tableName: 'files', + + virtuals: { + url() { + return `${File.baseUrl}${this.get('path')}` + }, + + magnet() { + let meta = this.get('meta') + if (!meta.torrent) return '' + return 'magnet:?' + + 'xl=' + this.get('size') + + '&dn=' + encodeURIComponent(meta.torrent.name) + + '&xt=urn:btih:' + meta.torrent.hash + + meta.torrent.announce.map(item => ('&tr=' + encodeURIComponent(item))).join('') + }, + }, }, { - baseUrl: 'http://192.168.42.14', + baseUrl: config.get('upload:baseurl'), }) export default File diff --git a/api/file/routes.mjs b/api/file/routes.mjs index 4bcd930..6a36356 100644 --- a/api/file/routes.mjs +++ b/api/file/routes.mjs @@ -1,8 +1,11 @@ -import config from '../config' -import File from './model' -import * as multer from '../multer' -import { uploadFile } from '../media/upload' -import Jwt from '../jwt' +import { readFile } from 'fs' +import parseTorrent from 'parse-torrent' + +import config from '../config.mjs' +import File from './model.mjs' +import * as multer from '../multer.mjs' +import { uploadFile } from '../media/upload.mjs' +import Jwt from '../jwt.mjs' export default class FileRoutes { constructor(opts = {}) { @@ -18,16 +21,35 @@ export default class FileRoutes { let result = await this.multer.processBody(ctx) let token = this.jwt.signDirect({ site: config.get('upload:name') }, config.get('upload:secret')) + let meta = {} - return ctx.throw(422, 'Unable to process for now') + if (result.originalname.endsWith('.torrent')) { + let fileContent = await new Promise((res, rej) => { + readFile(result.path, (err, data) => { + if (err) return rej(err) + res(data) + }) + }) + let torrent = parseTorrent(fileContent) + meta = { + torrent: { + name: torrent.name, + announce: torrent.announce, + hash: torrent.infoHash, + files: torrent.files.map(file => ({ name: file.name, size: file.length })), + }, + } + } let file = await this.uploadFile(token, result.path) ctx.body = await this.File.create({ filename: result.originalname, filetype: result.mimetype, + path: file.path, article_id: ctx.params.articleId, size: result.size, staff_id: ctx.state.user.id, + meta: meta, }) } diff --git a/api/index/routes.mjs b/api/index/routes.mjs index 3e875f7..f51289e 100644 --- a/api/index/routes.mjs +++ b/api/index/routes.mjs @@ -1,10 +1,7 @@ -import dot from 'dot' -import fs from 'fs' - export default class IndexRoutes { constructor(opts = {}) { this.indexBody = '' - }) + } async sendIndex(ctx) { ctx.body = this.indexBody diff --git a/api/jwt.mjs b/api/jwt.mjs index 0cabbaf..4ea8a83 100644 --- a/api/jwt.mjs +++ b/api/jwt.mjs @@ -1,8 +1,8 @@ import _ from 'lodash' import jwt from 'jsonwebtoken' import koaJwt from 'koa-jwt' -import Staff from './staff/model' -import config from './config' +import Staff from './staff/model.mjs' +import config from './config.mjs' export default class Jwt { constructor(opts = {}) { diff --git a/api/log.mjs b/api/log.mjs index 2ecca8d..b15f2dc 100644 --- a/api/log.mjs +++ b/api/log.mjs @@ -1,6 +1,6 @@ import bunyan from 'bunyan-lite' -import config from './config' -import * as defaults from './defaults' +import config from './config.mjs' +import * as defaults from './defaults.mjs' // Clone the settings as we will be touching // on them slightly. diff --git a/api/media/model.mjs b/api/media/model.mjs index f2eaf69..323cf9c 100644 --- a/api/media/model.mjs +++ b/api/media/model.mjs @@ -1,5 +1,6 @@ import path from 'path' -import bookshelf from '../bookshelf' +import bookshelf from '../bookshelf.mjs' +import config from '../config.mjs' /* @@ -51,7 +52,7 @@ const Media = bookshelf.createModel({ }, }, }, { - baseUrl: 'http://192.168.42.14', + baseUrl: config.get('upload:baseurl'), getSubUrl(input, size) { if (!input) return input diff --git a/api/media/resize.mjs b/api/media/resize.mjs index 6c4963c..644d5b2 100644 --- a/api/media/resize.mjs +++ b/api/media/resize.mjs @@ -1,5 +1,5 @@ import sharp from 'sharp' -import Media from './model' +import Media from './model.mjs' export default class Resizer { constructor(opts = {}) { @@ -15,7 +15,7 @@ export default class Resizer { return this.sharp(input) .resize(360, 360, { fit: sharp.fit.inside, - withoutEnlargement: true + withoutEnlargement: true, }) .toFile(output) .then(() => output) @@ -27,7 +27,7 @@ export default class Resizer { return this.sharp(input) .resize(700, 700, { fit: sharp.fit.inside, - withoutEnlargement: true + withoutEnlargement: true, }) .toFile(output) .then(() => output) diff --git a/api/media/routes.mjs b/api/media/routes.mjs index 8eb886e..70c265e 100644 --- a/api/media/routes.mjs +++ b/api/media/routes.mjs @@ -1,9 +1,9 @@ -import config from '../config' -import Media from './model' -import * as multer from '../multer' -import Resizer from './resize' -import { uploadFile } from './upload' -import Jwt from '../jwt' +import config from '../config.mjs' +import Media from './model.mjs' +import * as multer from '../multer.mjs' +import Resizer from './resize.mjs' +import { uploadFile } from './upload.mjs' +import Jwt from '../jwt.mjs' export default class MediaRoutes { constructor(opts = {}) { diff --git a/api/media/upload.mjs b/api/media/upload.mjs index 019cec2..17e667f 100644 --- a/api/media/upload.mjs +++ b/api/media/upload.mjs @@ -1,7 +1,7 @@ import http from 'http' import path from 'path' import fs from 'fs' -import config from '../config' +import config from '../config.mjs' let stub diff --git a/api/parser/middleware.mjs b/api/parser/middleware.mjs index a15843c..5eafc82 100644 --- a/api/parser/middleware.mjs +++ b/api/parser/middleware.mjs @@ -1,6 +1,6 @@ import format from 'format-link-header' -import * as pagination from './pagination' +import * as pagination from './pagination.mjs' export default class ParserMiddleware { constructor(opts = {}) { diff --git a/api/parser/pagination.mjs b/api/parser/pagination.mjs index fc393cb..11f78c4 100644 --- a/api/parser/pagination.mjs +++ b/api/parser/pagination.mjs @@ -1,6 +1,6 @@ import _ from 'lodash' import { format } from 'url' -import config from '../config' +import config from '../config.mjs' function limit(value, min, max, fallback) { let out = parseInt(value, 10) diff --git a/api/router.mjs b/api/router.mjs index 8154fa9..fc95621 100644 --- a/api/router.mjs +++ b/api/router.mjs @@ -1,13 +1,13 @@ /* eslint max-len: 0 */ 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' +import access from './access/index.mjs' +import AuthRoutes from './authentication/routes.mjs' +import MediaRoutes from './media/routes.mjs' +import FileRoutes from './file/routes.mjs' +import PageRoutes from './page/routes.mjs' +import ArticleRoutes from './article/routes.mjs' +import { restrict } from './access/middleware.mjs' const router = new Router() @@ -36,6 +36,7 @@ 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/pages/:pageId/articles', article.getAllPageArticles.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)) diff --git a/api/setup.mjs b/api/setup.mjs index 8250c4d..3728a66 100644 --- a/api/setup.mjs +++ b/api/setup.mjs @@ -1,7 +1,7 @@ import _ from 'lodash' -import config from './config' -import log from './log' +import config from './config.mjs' +import log from './log.mjs' import knex from 'knex' // This is important for setup to run cleanly. diff --git a/api/staff/model.mjs b/api/staff/model.mjs index bd383c3..5fa5be4 100644 --- a/api/staff/model.mjs +++ b/api/staff/model.mjs @@ -1,4 +1,4 @@ -import bookshelf from '../bookshelf' +import bookshelf from '../bookshelf.mjs' /* Staff model: { diff --git a/app/admin/articles.js b/app/admin/articles.js index b42e854..f16e7ac 100644 --- a/app/admin/articles.js +++ b/app/admin/articles.js @@ -1,19 +1,39 @@ const m = require('mithril') -const Authentication = require('../authentication') -const { getAllArticles, removeArticle } = require('../api/article') +const { getAllArticlesPagination, removeArticle } = require('../api/article') +const { fetchPage } = require('../api/pagination') const Dialogue = require('../widgets/dialogue') +const Pages = require('../widgets/pages') const AdminArticles = { oninit: function(vnode) { - this.loading = true this.error = '' + this.lastpage = m.route.param('page') || '1' this.articles = [] this.removeArticle = null - getAllArticles() + this.fetchArticles(vnode) + }, + + onupdate: function(vnode) { + if (m.route.param('page') && m.route.param('page') !== this.lastpage) { + this.fetchArticles(vnode) + } + }, + + fetchArticles: function(vnode) { + this.loading = true + this.links = null + this.lastpage = m.route.param('page') || '1' + + return fetchPage(getAllArticlesPagination({ + per_page: 10, + page: this.lastpage, + includes: ['parent'], + })) .then(function(result) { - vnode.state.articles = result + vnode.state.articles = result.data + vnode.state.links = result.links }) .catch(function(err) { vnode.state.error = err.message @@ -57,26 +77,26 @@ const AdminArticles = { 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('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), + (this.loading + ? m('div.loading-spinner.full') + : m('table', [ m('thead', m('tr', [ m('th', 'Title'), @@ -87,10 +107,14 @@ const AdminArticles = { ]) ), m('tbody', this.articles.map(AdminArticles.drawArticle.bind(this, vnode))), - ]), - ]), - ]) - ), + ]) + ), + m(Pages, { + base: '/admin/articles', + links: this.links, + }), + ]), + ]), m(Dialogue, { hidden: vnode.state.removeArticle === null, title: 'Delete ' + (vnode.state.removeArticle ? vnode.state.removeArticle.name : ''), diff --git a/app/admin/articles.scss b/app/admin/articles.scss index 5a91ee6..64a1c81 100644 --- a/app/admin/articles.scss +++ b/app/admin/articles.scss @@ -24,7 +24,7 @@ article.editarticle { margin: 0 0 20px; .inside { - height: 100px; + height: 150px; } } @@ -54,11 +54,15 @@ article.editarticle { width: 240px; height: 50px; position: relative; + + &.full { + width: 100%; + } } .fileupload { align-self: center; - padding: 10.5em; + padding: 0.5em; margin: 0.5em 0; min-width: 250px; border: none; @@ -80,6 +84,23 @@ article.editarticle { z-index: 2; } } + + files { + align-items: stretch; + width: 100%; + display: flex; + flex-direction: column; + padding: 10px 40px 0; + text-align: left; + + h4 { + font-size: 1.1em; + font-weight: bold; + padding: 0 5px 5px; + margin-bottom: 10px; + border-bottom: 1px solid $border; + } + } } table span.subarticle { diff --git a/app/admin/editarticle.js b/app/admin/editarticle.js index 7a8c8b7..05d708f 100644 --- a/app/admin/editarticle.js +++ b/app/admin/editarticle.js @@ -4,6 +4,8 @@ const Authentication = require('../authentication') const FileUpload = require('../widgets/fileupload') const Froala = require('./froala') const { Tree } = require('../api/page') +const { uploadFile } = require('../api/file') +const Fileinfo = require('../widgets/fileinfo') const { createArticle, updateArticle, getArticle } = require('../api/article') const EditArticle = { @@ -28,8 +30,30 @@ const EditArticle = { }, oninit: function(vnode) { - this.loading = m.route.param('key') !== 'add' - this.creating = m.route.param('key') === 'add' + this.froala = null + this.loadedFroala = Froala.loadedFroala + + if (!this.loadedFroala) { + Froala.createFroalaScript() + .then(function() { + vnode.state.loadedFroala = true + m.redraw() + }) + } + + this.fetchArticle(vnode) + }, + + onupdate: function(vnode) { + if (this.lastid !== m.route.param('id')) { + this.fetchArticle(vnode) + } + }, + + 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 = { @@ -38,13 +62,14 @@ const EditArticle = { description: '', media: null, banner: null, + files: [], } this.editedPath = false this.froala = null this.loadedFroala = Froala.loadedFroala - if (m.route.param('key') !== 'add') { - getArticle(m.route.param('key')) + if (this.lastid !== 'add') { + getArticle(this.lastid) .then(function(result) { vnode.state.editedPath = true vnode.state.article = result @@ -57,14 +82,6 @@ const EditArticle = { m.redraw() }) } - - if (!this.loadedFroala) { - Froala.createFroalaScript() - .then(function() { - vnode.state.loadedFroala = true - m.redraw() - }) - } }, updateValue: function(name, e) { @@ -83,16 +100,22 @@ const EditArticle = { } }, - fileUploaded: function(type, media) { + mediaUploaded: function(type, media) { this.article[type] = media }, + mediaRemoved: function(type) { + this.article[type] = null + }, + 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' + } else { + this.error = '' } if (this.error) return @@ -126,6 +149,7 @@ const EditArticle = { if (vnode.state.article.id) { res.media = vnode.state.article.media res.banner = vnode.state.article.banner + res.files = vnode.state.article.files vnode.state.article = res } else { m.route.set('/admin/articles/' + res.id) @@ -144,6 +168,19 @@ const EditArticle = { if (!event.target.files[0]) return vnode.state.error = '' vnode.state.loadingFile = true + + 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() { @@ -175,17 +212,19 @@ const 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 = '' } + onclick: function() { vnode.state.error = '' }, }, this.error), m(FileUpload, { - onupload: this.fileUploaded.bind(this, 'banner'), + onupload: this.mediaUploaded.bind(this, 'banner'), onerror: function(e) { vnode.state.error = e }, + ondelete: this.mediaRemoved.bind(this, 'banner'), media: this.article && this.article.banner, }), m(FileUpload, { class: 'cover', useimg: true, - onupload: this.fileUploaded.bind(this, 'media'), + onupload: this.mediaUploaded.bind(this, 'media'), + ondelete: this.mediaRemoved.bind(this, 'media'), onerror: function(e) { vnode.state.error = e }, media: this.article && this.article.media, }), @@ -226,15 +265,23 @@ const EditArticle = { 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), - ]), + this.article.files.length + ? m('files', [ + m('h4', 'Files'), + this.article.files.map(function(item) { return m(Fileinfo, { file: item }) }), + ]) + : null, + this.article.id + ? m('div.fileupload', [ + 'Add file', + m('input', { + accept: '*', + type: 'file', + onchange: this.uploadFile.bind(this, vnode), + }), + (vnode.state.loadingFile ? m('div.loading-spinner') : null), + ]) + : null, ]), ]) ) diff --git a/app/admin/editpage.js b/app/admin/editpage.js index d40e86e..5f9b406 100644 --- a/app/admin/editpage.js +++ b/app/admin/editpage.js @@ -84,6 +84,10 @@ const EditPage = { this.page[type] = media }, + fileRemoved: function(type) { + this.page[type] = null + }, + save: function(vnode, e) { e.preventDefault() if (!this.page.name) { @@ -105,8 +109,8 @@ const EditPage = { 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, + banner_id: this.page.banner && this.page.banner.id || null, + media_id: this.page.media && this.page.media.id || null, }) } else { promise = createPage({ @@ -114,8 +118,8 @@ const EditPage = { 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, + banner_id: this.page.banner && this.page.banner.id || null, + media_id: this.page.media && this.page.media.id || null, }) } @@ -135,6 +139,8 @@ const EditPage = { vnode.state.loading = false m.redraw() }) + + return false }, view: function(vnode) { @@ -147,16 +153,18 @@ const EditPage = { ? [ m('span', 'Actions:'), m(m.route.Link, { href: '/page/' + this.page.path }, 'View page'), + m(m.route.Link, { href: '/admin/pages/add' }, 'Create new 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 = '' } + onclick: function() { vnode.state.error = '' }, }, this.error), m(FileUpload, { onupload: this.fileUploaded.bind(this, 'banner'), + ondelete: this.fileRemoved.bind(this, 'banner'), onerror: function(e) { vnode.state.error = e }, media: this.page && this.page.banner, }), @@ -164,6 +172,7 @@ const EditPage = { class: 'cover', useimg: true, onupload: this.fileUploaded.bind(this, 'media'), + ondelete: this.fileRemoved.bind(this, 'media'), onerror: function(e) { vnode.state.error = e }, media: this.page && this.page.media, }), @@ -173,7 +182,9 @@ const EditPage = { 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) })), + }, 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', diff --git a/app/admin/pages.js b/app/admin/pages.js index a42949f..dc56719 100644 --- a/app/admin/pages.js +++ b/app/admin/pages.js @@ -63,7 +63,7 @@ const AdminPages = { 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))) }, @@ -80,7 +80,7 @@ const AdminPages = { m('header', m('h1', 'All pages')), m('div.error', { hidden: !this.error, - onclick: function() { vnode.state.error = '' } + onclick: function() { vnode.state.error = '' }, }, this.error), m('table', [ m('thead', diff --git a/app/admin/pages.scss b/app/admin/pages.scss index 2fb58bb..17a3b02 100644 --- a/app/admin/pages.scss +++ b/app/admin/pages.scss @@ -24,7 +24,7 @@ article.editpage { margin: 0 0 20px; .inside { - height: 100px; + height: 150px; } } diff --git a/app/api/article.js b/app/api/article.js index fde8c23..361c560 100644 --- a/app/api/article.js +++ b/app/api/article.js @@ -23,10 +23,55 @@ exports.getAllArticles = function() { }) } +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?' + extra +} + +exports.getAllPageArticles = function(pageId, includes) { + return sendRequest({ + method: 'GET', + url: '/api/pages/' + pageId + '/articles?includes=' + includes.join(','), + }) +} + +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?' + extra +} + exports.getArticle = function(id) { return sendRequest({ method: 'GET', - url: '/api/articles/' + id + '?includes=media,parent,banner', + url: '/api/articles/' + id + '?includes=media,parent,banner,files', }) } diff --git a/app/api/common.js b/app/api/common.js index 315f262..9eaa822 100644 --- a/app/api/common.js +++ b/app/api/common.js @@ -1,14 +1,38 @@ const m = require('mithril') const Authentication = require('../authentication') -exports.sendRequest = function(options) { +exports.sendRequest = function(options, isPagination) { let token = Authentication.getToken() + let pagination = isPagination if (token) { options.headers = options.headers || {} options.headers['Authorization'] = 'Bearer ' + token } + options.extract = function(xhr) { + let out = null + if (pagination && xhr.status < 300) { + let headers = {} + + xhr.getAllResponseHeaders().split('\r\n').forEach(function(item) { + var splitted = item.split(': ') + headers[splitted[0]] = splitted[1] + }) + + out = { + headers: headers || {}, + data: JSON.parse(xhr.responseText), + } + } else { + out = JSON.parse(xhr.responseText) + } + if (xhr.status >= 300) { + throw out + } + return out + } + return m.request(options) .catch(function (error) { if (error.code === 403) { diff --git a/app/api/file.js b/app/api/file.js new file mode 100644 index 0000000..2e9842b --- /dev/null +++ b/app/api/file.js @@ -0,0 +1,12 @@ +const { sendRequest } = require('./common') + +exports.uploadFile = function(articleId, file) { + let formData = new FormData() + formData.append('file', file) + + return sendRequest({ + method: 'POST', + url: '/api/articles/' + articleId + '/file', + body: formData, + }) +} diff --git a/app/api/pagination.js b/app/api/pagination.js new file mode 100644 index 0000000..c446eb6 --- /dev/null +++ b/app/api/pagination.js @@ -0,0 +1,16 @@ +const parse = require('parse-link-header') +const { sendRequest } = require('./common') + +exports.fetchPage = function(url) { + return sendRequest({ + method: 'GET', + url: url, + }, true) + .then(result => { + return { + data: result.data, + links: parse(result.headers.link || ''), + total: Number(result.headers.pagination_total || '0'), + } + }) +} diff --git a/app/app.scss b/app/app.scss index 6e0ac0e..9de8923 100644 --- a/app/app.scss +++ b/app/app.scss @@ -17,7 +17,7 @@ body, h1, h2, h3, h4, h5, h6, p { } body { - height: 100%; + min-height: 100%; font-family: Arial, Helvetica, sans-serif; } @@ -50,9 +50,15 @@ img { animation: spinner-loader .6s linear infinite; } +.maincontainer { + width: 100%; + min-height: 100vh; + display: flex; + flex-direction: column; +} + .container { width: 100%; - height: 100%; display: flex; flex-direction: column; } @@ -186,37 +192,38 @@ table { border-collapse: collapse; border-spacing: 0; font-size: 0.8em; -} -table thead th { - background-color: $headcolor; - border: solid 1px $bordercolor; - color: $headtext; - padding: 10px; - text-align: left; -} -table tbody td { - text-align: left; - border: solid 1px $bordercolor; - color: #333; - padding: 10px; -} -table a, -table a:visited, -table a:hover { - text-decoration: none; - color: $secondary-bg; - font-weight: bold; -} -table button { - color: $secondary-bg; - background: transparent; - border: 1px solid $secondary-bg; -} + thead th { + background-color: $headcolor; + border: solid 1px $bordercolor; + color: $headtext; + padding: 10px; + text-align: left; + } + tbody td { + text-align: left; + border: solid 1px $bordercolor; + color: #333; + padding: 10px; + } + a, + a:visited, + a:hover { + text-decoration: none; + color: $secondary-dark-bg; + font-weight: bold; + } -table td.right, -table th.right { - text-align: right; + button { + color: $secondary-dark-bg; + background: transparent; + border: 1px solid $secondary-dark-bg; + } + + td.right, + th.right { + text-align: right; + } } .floating-container { @@ -237,3 +244,4 @@ table th.right { @import 'admin/admin'; @import 'widgets/common'; @import 'pages/page'; +@import 'frontpage/frontpage' diff --git a/app/frontpage/frontpage.js b/app/frontpage/frontpage.js index 6a3f49f..f25f35a 100644 --- a/app/frontpage/frontpage.js +++ b/app/frontpage/frontpage.js @@ -1,12 +1,78 @@ const m = require('mithril') +const { getAllArticlesPagination } = require('../api/article') +const { fetchPage } = require('../api/pagination') +const Pages = require('../widgets/pages') +const Newsitem = require('../widgets/newsitem') + module.exports = { - view: function() { - return m('article', [ - m('header', [ - m('h1', 'Welcome to NFP Moe'), - m('span.meta', 'Last updated many years ago'), + oninit: function(vnode) { + this.error = '' + this.loading = false + this.featured = null + this.links = null + this.fetchArticles(vnode) + }, + + onupdate: function(vnode) { + if (this.lastpage !== (m.route.param('page') || '1')) { + this.fetchArticles(vnode) + } + }, + + fetchArticles(vnode) { + this.error = '' + this.loading = true + this.links = null + this.articles = [] + this.lastpage = m.route.param('page') || '1' + + return fetchPage(getAllArticlesPagination({ + per_page: 10, + page: this.lastpage, + includes: ['parent', 'files', 'media', 'banner'], + })) + .then(function(result) { + vnode.state.articles = result.data + vnode.state.links = result.links + + for (var i = result.data.length - 1; i >= 0; i--) { + if (result.data[i].banner) { + vnode.state.featured = result.data[i] + } + } + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + view: function(vnode) { + return [ + (this.featured && this.featured.banner + ? m('a.frontpage-banner', { + href: '/article/' + this.featured.path, + style: { 'background-image': 'url("' + this.featured.banner.url + '")' }, + }, + this.featured.name + ) + : null), + m('frontpage', [ + (this.loading + ? m('div.loading-spinner') + : null), + this.articles.map(function(article) { + return m(Newsitem, article) + }), + m(Pages, { + base: '/', + links: this.links, + }), ]), - ]) - } + ] + }, } diff --git a/app/frontpage/frontpage.scss b/app/frontpage/frontpage.scss new file mode 100644 index 0000000..9102ff2 --- /dev/null +++ b/app/frontpage/frontpage.scss @@ -0,0 +1,32 @@ +.frontpage-banner { + background-color: #999999; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + height: 150px; + width: 100%; + max-width: 1920px; + align-self: center; + flex: 0 0 150px; + margin-bottom: 20px; + color: white; + text-shadow: 0 0 0.3em #000000; + text-align: right; + font-size: 1.6em; + padding: 10px 20px; +} + +frontpage { + display: flex; + flex-direction: column; + align-self: center; + margin: 0 20px; + padding: 0 20px; + width: calc(100% - 40px); + max-width: 1000px; + flex: 2 0 0; + + .loading-spinner { + height: 100px; + } +} diff --git a/app/index.js b/app/index.js index 46f0c32..146460d 100644 --- a/app/index.js +++ b/app/index.js @@ -2,7 +2,6 @@ const m = require('mithril') m.route.prefix = '' -const Authentication = require('./authentication') const Menu = require('./menu/menu') const Frontpage = require('./frontpage/frontpage') const Login = require('./login/login') @@ -20,10 +19,10 @@ m.route(mainRoot, '/', { '/': Frontpage, '/login': Login, '/logout': Logout, - '/page/:key': Page, + '/page/:id': Page, '/admin/pages': AdminPages, '/admin/pages/:key': EditPage, '/admin/articles': AdminArticles, - '/admin/articles/:key': EditArticle, + '/admin/articles/:id': EditArticle, }) m.mount(menuRoot, Menu) diff --git a/app/menu/menu.js b/app/menu/menu.js index 2f7e4cc..0fb6c4a 100644 --- a/app/menu/menu.js +++ b/app/menu/menu.js @@ -1,6 +1,6 @@ const m = require('mithril') const Authentication = require('../authentication') -const { getAllPages, Tree, getTree } = require('../api/page') +const { Tree, getTree } = require('../api/page') const Menu = { currentActive: 'home', @@ -36,20 +36,26 @@ const Menu = { view: function() { return [ m('div.top', [ - m('h2', 'NFP Moe'), + m(m.route.Link, + { href: '/', class: 'logo' }, + m('h2', 'NFP Moe') + ), m('aside', Authentication.currentUser ? [ - m('p', 'Welcome ' + Authentication.currentUser.email), + m('p', [ + 'Welcome ' + Authentication.currentUser.email, + m(m.route.Link, { href: '/logout' }, 'Logout'), + ]), (Authentication.currentUser.level >= 100 ? [ m(m.route.Link, { href: '/admin/pages' }, 'Pages'), m(m.route.Link, { href: '/admin/articles' }, 'Articles'), + m(m.route.Link, { href: '/admin/articles/add' }, 'Create article'), ] : null ), - m(m.route.Link, { href: '/logout' }, 'Logout') ] : [ - m(m.route.Link, { href: '/login' }, 'Login') - ]) + m(m.route.Link, { href: '/login' }, 'Login'), + ]), ]), m('nav', [ m(m.route.Link, { @@ -62,7 +68,7 @@ const Menu = { m(m.route.Link, { href: '/page/' + page.path, class: Menu.currentActive === ('/page/' + page.path) ? 'active' : '', - }, page.name) + }, page.name), ]) } return m(m.route.Link, { @@ -73,7 +79,7 @@ const Menu = { ]), Menu.error ? m('div.menuerror', Menu.error) : null, ] - } + }, } module.exports = Menu diff --git a/app/menu/menu.scss b/app/menu/menu.scss index ab0f438..994b67f 100644 --- a/app/menu/menu.scss +++ b/app/menu/menu.scss @@ -5,28 +5,40 @@ flex-direction: column; .top { - background: url('./img/logo.png') 25px center no-repeat $primary-dark-bg; + background: $primary-dark-bg; color: $primary-dark-fg; - padding: 0 10px 0 120px; + padding: 0 10px 0 0; height: 100px; display: flex; + a.logo { + background: url('./img/logo.png') 25px center no-repeat transparent; + padding-left: 120px; + display: flex; + color: $primary-dark-fg; + text-decoration: none; + } + h2 { - flex-grow: 2; align-self: center; font-size: 30px; } aside { + flex-grow: 2; display: flex; flex-direction: column; - align-items: center; + align-items: flex-end; padding: 10px 0; p { font-size: 0.8em; color: $meta-light-fg; padding-bottom: 5px; + + a { + margin-left: 5px; + } } a, a:visited { diff --git a/app/pages/page.js b/app/pages/page.js index df5ee87..0629137 100644 --- a/app/pages/page.js +++ b/app/pages/page.js @@ -1,12 +1,23 @@ const m = require('mithril') const { getPage } = require('../api/page') +const { getAllPageArticlesPagination } = require('../api/article') +const { fetchPage } = require('../api/pagination') const Authentication = require('../authentication') const Newsentry = require('../widgets/newsentry') +const Pages = require('../widgets/pages') const Page = { oninit: function(vnode) { - this.path = m.route.param('key') this.error = '' + this.lastpage = m.route.param('page') || '1' + this.loadingnews = false + this.fetchPage(vnode) + }, + + fetchPage: function(vnode) { + this.path = m.route.param('id') + this.news = [] + this.newslinks = null this.page = { id: 0, name: '', @@ -14,7 +25,6 @@ const Page = { description: '', media: null, } - this.loading = true getPage(this.path) @@ -25,7 +35,37 @@ const Page = { vnode.state.error = err.message }) .then(function() { - vnode.state.loading = false + return vnode.state.fetchArticles(vnode) + }) + }, + + onupdate: function(vnode) { + if (this.path !== m.route.param('id')) { + 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' + + return fetchPage(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 + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = vnode.state.loadingnews = false m.redraw() }) }, @@ -35,7 +75,7 @@ const Page = { 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, + 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' : '', @@ -44,7 +84,7 @@ const Page = { ? 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) + return m(m.route.Link, { href: '/page/' + page.path }, page.name) }), ]) : null, @@ -52,27 +92,35 @@ const Page = { ? 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 + this.news.length && this.page.description ? m('aside.news', [ - m('h4', 'Latest updates under ' + this.page.name + ':'), - this.page.news.map(function(article) { + m('h4', 'Latest posts under ' + this.page.name + ':'), + this.loadingnews ? m('div.loading-spinner') : this.news.map(function(article) { return m(Newsentry, article) }), + m(Pages, { + base: '/page/' + this.page.path, + links: this.newslinks, + }), ]) - : null + : null, ]) - : null, + : this.news.length + ? m('aside.news.single', [ + this.page.media ? m('img.page-cover', { src: this.page.media.url } ) : null, + m('h4', 'Latest posts under ' + this.page.name + ':'), + this.loadingnews ? m('div.loading-spinner') : this.news.map(function(article) { + return m(Newsentry, article) + }), + m(Pages, { + base: '/page/' + this.page.path, + links: this.newslinks, + }), + ]) + : this.page.media + ? m('img.page-cover.single', { src: this.page.media.url } ) + : 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:'), diff --git a/app/pages/page.scss b/app/pages/page.scss index 9fd3eeb..04ca7ec 100644 --- a/app/pages/page.scss +++ b/app/pages/page.scss @@ -17,18 +17,26 @@ article.page { } .page-banner { - background-size: auto 100%; + background-size: cover; background-repeat: no-repeat; background-position: center; - height: 100px; + height: 150px; width: 100%; max-width: 1920px; align-self: center; - flex: 0 0 100px; + flex: 0 0 150px; } .page-cover { margin: 0 -10px 20px; + + &.single { + margin: 0 20px 20px; + padding: 0 20px; + width: calc(100% - 40px); + max-width: 800px; + flex: 2 0 0; + } } .admin-actions { @@ -49,14 +57,14 @@ article.page { margin: 0 0 10px; } - a { - display: inline-block; - padding-top: 5px; - text-decoration: none; - color: $secondary-bg; - font-size: 14px; - font-weight: bold; - } + // a { + // display: inline-block; + // padding-top: 5px; + // text-decoration: none; + // color: $secondary-dark-bg; + // font-size: 14px; + // font-weight: bold; + // } } .container { @@ -88,6 +96,10 @@ article.page { a { padding: 5px 5px 0px; display: block; + text-decoration: none; + color: $secondary-dark-bg; + font-size: 14px; + font-weight: bold; } } @@ -112,6 +124,11 @@ aside.news { width: 100%; align-self: center; + .loading-spinner { + position: relative; + height: 133px; + } + newsentry { margin: 0 0 10px; } @@ -119,16 +136,26 @@ aside.news { &.single { max-width: 800px; flex: 2 0 0; + padding: 0 20px 10px; border-top: none; margin-top: 0; + align-self: flex-start; & > h4 { - display: none; + padding: 0 5px 5px; + border-bottom: 1px solid $border; } } } -@media screen and (max-device-width: 639px){ +@media screen and (max-width: 800px){ + article.page aside.sidebar { + width: 200px; + flex: 0 0 200px; + } +} + +@media screen and (max-width: 639px){ article.page .container { flex-direction: column !important; } @@ -142,7 +169,7 @@ aside.news { } } -@media screen and (max-device-width: 360px){ +@media screen and (max-width: 360px){ article.page { .container { flex-direction: column; diff --git a/app/widgets/common.scss b/app/widgets/common.scss index b64eef0..4fb3465 100644 --- a/app/widgets/common.scss +++ b/app/widgets/common.scss @@ -16,30 +16,29 @@ fileupload { .showbordericon { border: 3px solid $title-fg; border-style: dashed; - background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHaSURBVHhe7dfPScRAAIXxFSxAPHjUkrx61DaswCJsw5MdCDYkgs6DDIQlu5l/Sd5Ovh88WDJ7yPCdcgAAAAAAAEDvHofBwFPYzzD9xoaew37D/obpt55hAy9h4xjjKDrDik7FiCPKiuZixBFlBakx4oiyoNwYcURZQGmMOKI0VBsjjigNtIoRR5QKrWPEEaVASoyPiWdx5840omRIifEe9nD0bDyd6T9TZ3FESZAa4yrsfvTseDrTf4hSISeGzAURohTKjSEpQYQomUpiSGoQIUqi0hiSE0SIMqMmhuQGEaKcUBtDSoIIUY60iCGlQYQog1YxpCaI7D5KyxhSG0R2G+U67Cts6sJxOTGkRRBJiaJ31x26chv2HTZ14dwY0iqInIuid9a7d2kqSkkMaRlEpqJ0HSMaRymNIa2DyDjKLmJEuuhrWGkMWSKI6J30bruJ0cpSQVCIIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZghihiBmCGKGIGYIYoYgZggCAACAjd2FfXY+3fFinPtG6GUX9a1DEDMEMUMQMwQxcxP21vl0RwAAAAAAAGCnDod/1p4xx4l+w0cAAAAASUVORK5CYII='); + background-image: url('./img/upload.svg'); background-position: center; background-repeat: no-repeat; - background-size: 32px; + background-size: 50px; } .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='); + top: 5px; + right: 5px; + width: 50px; + height: 50px; + background-image: url('./img/upload.svg'); background-position: center; background-repeat: no-repeat; - background-size: 32px; + background-size: contain; } img { max-width: 600px; - width: calc(100% - 80px); + width: 100%; align-self: center; + min-height: 100px; } .display { @@ -70,6 +69,23 @@ fileupload { text-indent: -9999px; z-index: 2; } + + .remove { + border: none; + position: absolute; + top: 5px; + right: 60px; + width: 50px; + height: 50px; + background-image: url('./img/delete.svg'); + background-position: center; + background-repeat: no-repeat; + background-color: transparent; + background-size: contain; + z-index: 3; + outline: none; + cursor: pointer; + } } dialogue { @@ -79,41 +95,41 @@ dialogue { 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; -} + h2 { + background: $secondary-dark-bg; + color: $secondary-dark-fg; + font-size: 1.5em; + padding: 10px; + } -dialogue p { - padding: 10px; -} + p { + padding: 10px; + } -dialogue .buttons { - display: flex; - justify-content: space-around; - padding: 10px; -} + .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; -} + 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; -} + button.alert { + border-color: red; + color: red; + } -dialogue button.cancel { - border-color: #999; - color: #999; + button.cancel { + border-color: #999; + color: #999; + } } newsentry { @@ -121,24 +137,36 @@ newsentry { color: $meta-fg; font-size: 12px; - a { - &.cover { - flex-shrink: 0; - margin-right: 10px; + .title { + display: flex; + margin-bottom: 10px !important; - img { - max-height: 70px; - width: auto; - } + a { + text-decoration: none; + color: $secondary-dark-bg; + font-size: 14px; + font-weight: bold; } + } - &.nobg { - height: 70px; - width: 124px; - background: #ddd; + a.cover { + flex-shrink: 0; + margin-right: 10px; + width: 124px; + text-align: center; + + img { + max-height: 70px; + width: auto; } } + a.nobg { + height: 70px; + width: 124px; + background: #ddd; + } + .entrycontent { display: flex; flex-direction: column; @@ -146,9 +174,148 @@ newsentry { padding: 0 5px 5px; h3 { - margin-bottom: 10px !important; + margin-bottom: 0 !important; font-size: 1.3em; font-weight: bold; } } + + .entrymeta { + font-size: 10px; + color: $meta-fg; + font-weight: bold; + display: none; + } +} + +fileinfo { + padding: 0 5px; + margin-bottom: 5px; + + &.slim { + padding: 0; + margin: 0; + } + + .filetitle { + display: block; + line-height: 16px; + + .prefix { + font-weight: bold; + margin-right: 5px; + } + + a { + color: $secondary-dark-bg; + font-weight: bold; + text-decoration: none; + padding-right: 5px; + border-right: 1px solid $border; + margin-right: 5px; + display: inline-block; + } + } + + ul { + margin: 10px 0; + padding-left: 0; + list-style-type: disc; + list-style-position: inside; + + li { + padding-left: 20px; + } + } +} + +newsitem { + display: flex; + flex-direction: column; + font-size: 15px; + + .title { + text-decoration: none; + background: $secondary-bg; + color: $secondary-fg; + font-size: 1.2em; + font-weight: bold; + text-align: center; + padding: 5px 10px; + margin-bottom: 10px; + } + + .newsitemcontent { + display: flex; + } + + a.cover { + flex-shrink: 0; + margin-right: 10px; + width: 400px; + text-align: center; + + img { + max-height: 225px; + width: auto; + } + } + + a.nobg { + height: 225px; + width: 400px; + background: #ddd; + } + + .entrycontent { + display: flex; + flex-direction: column; + flex: 2 1 auto; + padding: 0 5px 5px; + + h3 { + margin-bottom: 0 !important; + font-size: 1.3em; + font-weight: bold; + } + + .fr-view { + margin-bottom: 10px; + } + + fileinfo { + font-size: 0.8em; + } + } + + .entrymeta { + flex-grow: 2; + font-size: 11px; + color: $meta-fg; + font-weight: bold; + display: flex; + align-items: flex-end; + padding: 10px 0; + } +} + +pages { + display: flex; + justify-content: center; + width: 100%; + + a, div { + display: block; + font-size: 0.9em; + max-width: 80px; + flex-grow: 2; + text-align: center; + padding: 10px !important; + margin-top: 10px; + } + + a { + color: $secondary-dark-bg; + cursor: pointer; + } } diff --git a/app/widgets/fileinfo.js b/app/widgets/fileinfo.js new file mode 100644 index 0000000..546a47b --- /dev/null +++ b/app/widgets/fileinfo.js @@ -0,0 +1,73 @@ +const m = require('mithril') + +const Fileinfo = { + getPrefix(vnode) { + if (!vnode.attrs.file.filename.endsWith('.torrent')) { + return vnode.attrs.file.filename.split('.').slice(-1) + } + if (vnode.attrs.file.filename.indexOf('720 ') >= 0) { + return '720p' + } + if (vnode.attrs.file.filename.indexOf('1080 ') >= 0) { + return '1080p' + } + if (vnode.attrs.file.filename.indexOf('480 ') >= 0) { + return '480p' + } + return 'Other' + }, + + getTitle(vnode) { + if (vnode.attrs.file.meta.torrent) { + return vnode.attrs.file.meta.torrent.name + } + return vnode.attrs.file.filename + }, + + getDownloadName(vnode) { + if (vnode.attrs.file.meta.torrent) { + return 'Torrent' + } + return 'Download' + }, + + getSize(orgSize) { + var size = orgSize + var i = -1 + var byteUnits = [' kB', ' MB', ' GB', ' TB', 'PB', 'EB', 'ZB', 'YB'] + do { + size = size / 1024 + i++ + } while (size > 1024) + + return Math.max(size, 0.1).toFixed(1) + byteUnits[i] + }, + + view: function(vnode) { + return m('fileinfo', { class: vnode.attrs.slim ? 'slim' : ''}, [ + m('div.filetitle', [ + m('span.prefix', this.getPrefix(vnode) + ':'), + m('a', { + target: '_blank', + href: vnode.attrs.file.url, + }, this.getDownloadName(vnode)), + vnode.attrs.file.magnet + ? m('a', { + href: vnode.attrs.file.magnet, + }, 'Magnet') + : null, + m('span', this.getTitle(vnode)), + ]), + vnode.attrs.file.meta.torrent && !vnode.attrs.slim + ? m('ul', vnode.attrs.file.meta.torrent.files.map(function(file) { + return m('li', [ + file.name + ' ', + m('span.meta', '(' + Fileinfo.getSize(file.size) + ')'), + ]) + })) + : null, + ]) + }, +} + +module.exports = Fileinfo diff --git a/app/widgets/fileupload.js b/app/widgets/fileupload.js index ad47e0b..8ee8ec6 100644 --- a/app/widgets/fileupload.js +++ b/app/widgets/fileupload.js @@ -17,6 +17,7 @@ const FileUpload = { vnode.state.updateError(vnode, err.message) }) .then(function() { + event.target.value = null vnode.state.loading = false m.redraw() }) @@ -50,7 +51,7 @@ const FileUpload = { : m('a.display.inside', { href: media.large_url, style: { - 'background-image': 'url(' + media.medium_url + ')', + 'background-image': 'url("' + media.large_url + '")', }, }, m('div.showicon')) : m('div.inside.showbordericon') @@ -60,9 +61,10 @@ const FileUpload = { type: 'file', onchange: this.uploadFile.bind(this, vnode), }), + (media && vnode.attrs.ondelete ? m('button.remove', { onclick: vnode.attrs.ondelete }) : null), (vnode.state.loading ? m('div.loading-spinner') : null), ]) - } + }, } module.exports = FileUpload diff --git a/app/widgets/newsentry.js b/app/widgets/newsentry.js index 2c1d4a3..c035412 100644 --- a/app/widgets/newsentry.js +++ b/app/widgets/newsentry.js @@ -1,6 +1,17 @@ const m = require('mithril') +const Fileinfo = require('./fileinfo') const Newsentry = { + strip: function(html) { + var doc = new DOMParser().parseFromString(html, 'text/html') + var out = doc.body.textContent || '' + var splitted = out.split('.') + if (splitted.length > 2) { + return splitted.slice(0, 2).join('.') + '...' + } + return out + }, + view: function(vnode) { return m('newsentry', [ vnode.attrs.media @@ -9,12 +20,21 @@ const Newsentry = { }, 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]) - ]) + m('div.title', [ + m(m.route.Link, + { href: '/article/' + vnode.attrs.path }, + m('h3', [vnode.attrs.name]) + ), + ]), + (vnode.attrs.files && vnode.attrs.files.length + ? vnode.attrs.files.map(function(file) { + return m(Fileinfo, { file: file, slim: true }) + }) + : vnode.attrs.description + ? m('span.entrydescription', Newsentry.strip(vnode.attrs.description)) + : null), + m('span.entrymeta', 'Posted ' + vnode.attrs.created_at.replace('T', ' ').split('.')[0]), + ]), ]) }, } diff --git a/app/widgets/newsitem.js b/app/widgets/newsitem.js new file mode 100644 index 0000000..4ff421c --- /dev/null +++ b/app/widgets/newsitem.js @@ -0,0 +1,33 @@ +const m = require('mithril') +const Fileinfo = require('./fileinfo') + +const Newsitem = { + view: function(vnode) { + return m('newsitem', [ + m(m.route.Link, + { href: '/article/' + vnode.attrs.path, class: 'title' }, + m('h3', [vnode.attrs.name]) + ), + m('div.newsitemcontent', [ + 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', [ + (vnode.attrs.description + ? m('.fr-view', m.trust(vnode.attrs.description)) + : null), + (vnode.attrs.files && vnode.attrs.files.length + ? vnode.attrs.files.map(function(file) { + return m(Fileinfo, { file: file }) + }) + : null), + m('span.entrymeta', 'Posted ' + vnode.attrs.created_at.replace('T', ' ').split('.')[0]), + ]), + ]), + ]) + }, +} + +module.exports = Newsitem diff --git a/app/widgets/pages.js b/app/widgets/pages.js new file mode 100644 index 0000000..f46733b --- /dev/null +++ b/app/widgets/pages.js @@ -0,0 +1,40 @@ +const m = require('mithril') + +const Pages = { + oninit: function(vnode) { + this.onpage = vnode.attrs.onpage || function() {} + }, + + view: function(vnode) { + if (!vnode.attrs.links) 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') + : 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) }, + }, 'Last') + : m('div'), + ]) + }, +} + +module.exports = Pages diff --git a/config/config.default.json b/config/config.default.json index 01fa98e..a4d31c4 100644 --- a/config/config.default.json +++ b/config/config.default.json @@ -39,6 +39,7 @@ "bcrypt": 5, "fileSize": 524288000, "upload": { + "baseurl": "http://192.168.42.14", "port": "2111", "host": "storage01.nfp.is", "name": "nfpmoe-dev", diff --git a/index.mjs b/index.mjs index aa2962d..4191cba 100644 --- a/index.mjs +++ b/index.mjs @@ -1,7 +1,7 @@ -import log from './api/log' +import log from './api/log.mjs' // Run the database script automatically. -import setup from './api/setup' +import setup from './api/setup.mjs' setup().catch(async (error) => { log.error({ code: error.code, message: error.message }, 'Error while preparing database') @@ -13,7 +13,7 @@ setup().catch(async (error) => { // process.exit(1) // }) }).then(() => - import('./server') + import('./server.mjs') ).catch(error => { log.error(error, 'Unknown error starting server') }) diff --git a/migrations/20190219105500_base.js b/migrations/20190219105500_base.js index 4031cb3..3914454 100644 --- a/migrations/20190219105500_base.js +++ b/migrations/20190219105500_base.js @@ -36,18 +36,6 @@ exports.up = function up(knex, Promise) { .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') @@ -90,13 +78,15 @@ exports.up = function up(knex, Promise) { }), knex.schema.createTable('files', function(table) { table.increments() - table.integer('file_id') - .references('files.id') + table.integer('articdle_id') + .references('articles.id') table.text('filename') table.text('filetype') + table.text('path') table.integer('size') table.integer('staff_id') .references('staff.id') + table.jsonb('meta') table.boolean('is_deleted') .notNullable() .default(false) diff --git a/nodemon.json b/nodemon.json index 075795d..d8e74ec 100644 --- a/nodemon.json +++ b/nodemon.json @@ -1,3 +1,8 @@ { - "ignore": ["app/**", "public/**"] + "ignore": ["app/**", "public/**"], + "watch": [ + "api/**", + "server*", + "config/**" + ] } \ No newline at end of file diff --git a/package.json b/package.json index 60f6616..3980ce8 100644 --- a/package.json +++ b/package.json @@ -6,17 +6,31 @@ "directories": { "test": "test" }, + "watch": { + "test": { + "patterns": [ + "{api,test}/*" + ], + "extensions": "js,mjs", + "quiet": false, + "inherit": true + } + }, "scripts": { "lint": "eslint .", "start": "node --experimental-modules index.mjs", - "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", + "build": "sass -s compressed app/app.scss public/assets/app.css && browserify -p tinyify --no-commondir -o public/assets/app.js app/index.js", + "build:check": "browserify -o public/assets/app.js app/index.js", "test": "echo \"Error: no test specified\" && exit 1", "watch:api": "nodemon --experimental-modules index.mjs | bunyan", "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" + "prod": "npm run build && npm start", + "docker": "docker run -it --rm --name nfp_moe -e knex__connection__host -e NODE_ENV -p 4030:4030 -v \"$PWD\":/usr/src/app -w /usr/src/app node", + "docker:install": "npm run docker -- npm install", + "docker:dev": "npm run docker -- npm run dev", + "docker:prod": "npm run docker -- npm run prod" }, "repository": { "type": "git", @@ -32,7 +46,6 @@ "@koa/cors": "^2.2.3", "bookshelf": "^0.15.1", "bunyan-lite": "^1.0.1", - "dot": "^1.1.2", "format-link-header": "^2.1.0", "googleapis": "^42.0.0", "http-errors": "^1.7.2", @@ -49,7 +62,7 @@ "nconf": "^0.10.0", "parse-torrent": "^7.0.1", "pg": "^7.8.0", - "sharp": "^0.21.3" + "sharp": "^0.22.1" }, "devDependencies": { "browserify": "^16.2.3", @@ -57,6 +70,7 @@ "mithril": "^2.0.3", "nodemon": "^1.18.10", "npm-run-all": "^4.1.5", + "parse-link-header": "^1.0.1", "sass": "^1.17.0", "tinyify": "^2.5.1", "watchify": "^3.11.0" diff --git a/public/assets/img/delete.svg b/public/assets/img/delete.svg new file mode 100644 index 0000000..cd44bfe --- /dev/null +++ b/public/assets/img/delete.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/public/assets/img/upload.svg b/public/assets/img/upload.svg new file mode 100644 index 0000000..2e98a84 --- /dev/null +++ b/public/assets/img/upload.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/index.html b/public/index.html index 1ab927f..ebfe42f 100644 --- a/public/index.html +++ b/public/index.html @@ -10,7 +10,7 @@ -
+
diff --git a/server.mjs b/server.mjs index ae33b19..7a06a0a 100644 --- a/server.mjs +++ b/server.mjs @@ -2,15 +2,15 @@ import Koa from 'koa' import bodyParser from 'koa-bodyparser' import cors from '@koa/cors' -import config from './api/config' -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' +import config from './api/config.mjs' +import router from './api/router.mjs' +import Jwt from './api/jwt.mjs' +import log from './api/log.mjs' +import { serve } from './api/serve.mjs' +import { mask } from './api/middlewares/mask.mjs' +import { errorHandler } from './api/error/middleware.mjs' +import { accessChecks } from './api/access/middleware.mjs' +import ParserMiddleware from './api/parser/middleware.mjs' const app = new Koa() const parser = new ParserMiddleware()