Compare commits
No commits in common. "master" and "d2090970457939509e4c2c45dad388e2afa9b00f" have entirely different histories.
master
...
d209097045
368 changed files with 8132 additions and 189650 deletions
48
.circleci/config.yml
Normal file
48
.circleci/config.yml
Normal file
|
@ -0,0 +1,48 @@
|
|||
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 & sed
|
||||
command: apk update && apk upgrade && apk add --no-cache bash git openssh sed
|
||||
- checkout
|
||||
- setup_remote_docker
|
||||
- run:
|
||||
name: Replace version in config
|
||||
command: |
|
||||
sed -i "s/circleci_version_number/${CIRCLE_BUILD_NUM}/g" config/config.default.json
|
||||
- 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} --all-tags
|
||||
- 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@212.30.212.2 "docker ${service_name} ${di}:build_${CIRCLE_BUILD_NUM} ${target_port} ${service_port}"
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
build_deploy:
|
||||
jobs:
|
||||
- build:
|
||||
context: org-global
|
3
.eslintignore
Normal file
3
.eslintignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
.circleci
|
||||
node_modules
|
||||
public
|
85
.eslintrc
Normal file
85
.eslintrc
Normal file
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 9,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"impliedStrict": true
|
||||
}
|
||||
},
|
||||
"globals": {
|
||||
"FroalaEditor": "readonly",
|
||||
"gapi": "readonly",
|
||||
"m": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"env": {
|
||||
"mocha": true,
|
||||
"node": true,
|
||||
"es6": true,
|
||||
"browser": true
|
||||
},
|
||||
"rules": {
|
||||
"require-await": 0,
|
||||
"array-callback-return": 2,
|
||||
"block-scoped-var": 2,
|
||||
"complexity": ["error", 40],
|
||||
"eqeqeq": [2, "smart"],
|
||||
"no-else-return": ["error", { "allowElseIf": false }],
|
||||
"no-extra-bind": 2,
|
||||
"no-implicit-coercion": 2,
|
||||
"no-invalid-this": 2,
|
||||
"no-loop-func": 2,
|
||||
"no-multi-spaces": 2,
|
||||
"no-multi-str": 2,
|
||||
"no-new": 2,
|
||||
"no-param-reassign": [2, {"props": false}],
|
||||
"no-return-assign": 2,
|
||||
"no-return-await": 2,
|
||||
"no-self-compare": 2,
|
||||
"no-sequences": 2,
|
||||
"no-throw-literal": 2,
|
||||
"no-unmodified-loop-condition": 2,
|
||||
"no-useless-call": 2,
|
||||
"no-useless-concat": 2,
|
||||
"no-useless-return": 2,
|
||||
"no-void": 2,
|
||||
"no-warning-comments": 2,
|
||||
"prefer-promise-reject-errors": 2,
|
||||
"no-catch-shadow": 2,
|
||||
"no-shadow": 2,
|
||||
"no-undef-init": 2,
|
||||
"no-undefined": 2,
|
||||
"no-use-before-define": 2,
|
||||
"no-new-require": 2,
|
||||
"no-sync": 2,
|
||||
"array-bracket-newline": [2, "consistent"],
|
||||
"block-spacing": [2, "always"],
|
||||
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
|
||||
"comma-dangle": [2, "always-multiline"],
|
||||
"comma-spacing": 2,
|
||||
"comma-style": 2,
|
||||
"computed-property-spacing": 2,
|
||||
"eol-last": 2,
|
||||
"func-call-spacing": 2,
|
||||
"key-spacing": 2,
|
||||
"keyword-spacing": 2,
|
||||
|
||||
"semi": [2, "never"],
|
||||
"max-len": [1, 120],
|
||||
"prefer-const": 0,
|
||||
"consistent-return": 0,
|
||||
"no-unused-vars": [
|
||||
2,
|
||||
{
|
||||
"args": "after-used",
|
||||
"argsIgnorePattern": "next|res|req|vnode"
|
||||
}
|
||||
],
|
||||
"generator-star-spacing": 0,
|
||||
"global-require": 0,
|
||||
"import/prefer-default-export": 0,
|
||||
"no-underscore-dangle": 0,
|
||||
"strict": 0,
|
||||
"require-yield": 0
|
||||
}
|
||||
}
|
|
@ -1,79 +0,0 @@
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: arch
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
- name: Check for new release
|
||||
run: |
|
||||
chmod +x ./7zas
|
||||
echo ""
|
||||
echo "Checking following projects:"
|
||||
for f in *; do
|
||||
[ -d "$f" ] && [ ! -L "$f" ] && [ ! "$f" = "base" ] && echo " * $f";
|
||||
done
|
||||
echo ""
|
||||
|
||||
|
||||
for f in *; do
|
||||
[ ! -d "$f" ] || [ -L "$f" ] || [ "$f" = "base" ] && continue;
|
||||
|
||||
echo ""
|
||||
echo "------------------------------------"
|
||||
echo ""
|
||||
echo "checking $f";
|
||||
cd $f
|
||||
|
||||
CURR_VER="$(cat package.json | jq -r .name)_v$(cat package.json | jq -r .version)"
|
||||
CURR_NAME="$(cat package.json | jq -r .name) v$(cat package.json | jq -r .version)"
|
||||
|
||||
echo "Checking https://git.nfp.is/api/v1/repos/${{ github.repository }}/releases for name ${CURR_NAME}"
|
||||
|
||||
if curl -s -X GET -H "Authorization: token ${{ secrets.deploytoken }}" https://git.nfp.is/api/v1/repos/${{ github.repository }}/releases | grep -o "\"name\"\:\"${CURR_NAME}\"" > /dev/null; then
|
||||
echo "Skipping ${{ github.job }} since $CURR_NAME already exists";
|
||||
cd ..
|
||||
continue;
|
||||
fi
|
||||
|
||||
echo "New release ${CURR_VER} found, running npm install..."
|
||||
|
||||
mv package.json fuck-you-npm-package.json
|
||||
mv build-package.json package.json
|
||||
npm install && npm run build
|
||||
|
||||
mv package.json build-package.json
|
||||
mv fuck-you-npm-package.json package.json
|
||||
|
||||
../7zas a -xr!*.xcf -mx9 "${CURR_VER}_build-sc.7z" package.json index.mjs api base public
|
||||
|
||||
echo "Creating ${CURR_VER} release on gitea"
|
||||
RELEASE_RESULT=$(curl \
|
||||
-X POST \
|
||||
-H "Authorization: token ${{ secrets.deploytoken }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://git.nfp.is/api/v1/repos/${{ github.repository }}/releases \
|
||||
-d "{\"tag_name\":\"${CURR_VER}\",\"name\":\"${CURR_NAME}\",\"body\":\"Automatic release from Appveyor from ${{ github.sha }} :\n\n${{ github.event.head_commit.message }}\"}")
|
||||
|
||||
RELEASE_ID=$(echo $RELEASE_RESULT | jq -r .id)
|
||||
echo "Adding ${CURR_VER}_build-sc.7z to release ${RELEASE_ID}"
|
||||
|
||||
curl \
|
||||
-X POST \
|
||||
-H "Authorization: token ${{ secrets.deploytoken }}" \
|
||||
-F "attachment=@${CURR_VER}_build-sc.7z" \
|
||||
https://git.nfp.is/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets
|
||||
|
||||
MAN_PORT=$(cat package.json | jq -r .port)
|
||||
MAN_NAME=$(cat package.json | jq -r .name)
|
||||
|
||||
echo "Deplying to production"
|
||||
echo "curl -X POST http://192.168.93.52:$MAN_PORT/update/$MAN_NAME"
|
||||
curl -X POST http://192.168.93.52:$MAN_PORT/update/$MAN_NAME
|
||||
|
||||
cd ..
|
||||
done
|
10
.gitignore
vendored
10
.gitignore
vendored
|
@ -58,7 +58,11 @@ typings/
|
|||
.env
|
||||
|
||||
# Local development config file
|
||||
config.json
|
||||
config/config.json
|
||||
package-lock.json
|
||||
**/public/**/app.js
|
||||
**/public/**/admin.js
|
||||
public/assets/app.js
|
||||
public/assets/app.css
|
||||
public/assets/app.css.map
|
||||
public/assets/admin.js
|
||||
public/assets/admin.css
|
||||
public/assets/admin.css.map
|
||||
|
|
BIN
7zas
BIN
7zas
Binary file not shown.
50
Dockerfile
Normal file
50
Dockerfile
Normal file
|
@ -0,0 +1,50 @@
|
|||
###########################
|
||||
# Angular
|
||||
###########################
|
||||
FROM node:12-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 git && \
|
||||
apk add vips-dev fftw-dev build-base --no-cache \
|
||||
--repository http://dl-3.alpinelinux.org/alpine/v3.10/community \
|
||||
--repository http://dl-3.alpinelinux.org/alpine/v3.10/main && \
|
||||
npm install && \
|
||||
apk del .build-deps gcc g++ make libc6-compat python && \
|
||||
apk del build-base && \
|
||||
npm run build
|
||||
|
||||
###########################
|
||||
# Server
|
||||
###########################
|
||||
FROM node:12-alpine
|
||||
|
||||
ENV HOME=/app
|
||||
|
||||
COPY index.mjs package.json $HOME/
|
||||
|
||||
WORKDIR $HOME
|
||||
|
||||
RUN apk add --update --no-cache --virtual .build-deps gcc g++ make libc6-compat python git && \
|
||||
apk add vips-dev fftw-dev build-base --no-cache \
|
||||
--repository http://dl-3.alpinelinux.org/alpine/v3.10/community \
|
||||
--repository http://dl-3.alpinelinux.org/alpine/v3.10/main && \
|
||||
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"]
|
|
@ -1 +1 @@
|
|||
# nfp_sites
|
||||
# nfp_moe
|
29
api/access/index.mjs
Normal file
29
api/access/index.mjs
Normal file
|
@ -0,0 +1,29 @@
|
|||
import _ from 'lodash'
|
||||
|
||||
const levels = {
|
||||
Normal: 1,
|
||||
Manager: 10,
|
||||
Admin: 100,
|
||||
}
|
||||
|
||||
levels.is = function is(ctx, level) {
|
||||
if (!_.isInteger(level)) throw new Error('AccessLevelDenied')
|
||||
if (!ctx.state.user || !ctx.state.user.level) return false
|
||||
if (ctx.state.user.level !== level) return false
|
||||
return true
|
||||
}
|
||||
|
||||
levels.atLeast = function atLeast(ctx, level) {
|
||||
if (!_.isInteger(level)) throw new Error('AccessLevelDenied')
|
||||
if (!ctx.state.user || !ctx.state.user.level) return false
|
||||
if (ctx.state.user.level < level) return false
|
||||
return true
|
||||
}
|
||||
|
||||
levels.ensure = function ensure(ctx, level) {
|
||||
if (!levels.atLeast(ctx, level)) {
|
||||
throw new Error('AccessLevelDenied')
|
||||
}
|
||||
}
|
||||
|
||||
export default levels
|
33
api/access/middleware.mjs
Normal file
33
api/access/middleware.mjs
Normal file
|
@ -0,0 +1,33 @@
|
|||
import orgAccess from './index.mjs'
|
||||
|
||||
export function accessChecks(opts = { }) {
|
||||
const access = opts.access || orgAccess
|
||||
|
||||
return (ctx, next) => {
|
||||
ctx.state.is = access.is.bind(access, ctx)
|
||||
ctx.state.atLeast = access.atLeast.bind(access, ctx)
|
||||
ctx.state.ensure = access.ensure.bind(access, ctx)
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
export function restrict(level = orgAccess.Normal) {
|
||||
return async (ctx, next) => {
|
||||
if (!ctx.headers.authorization && !ctx.query.token) {
|
||||
return ctx.throw(403, 'Authentication token was not found (did you forget to login?)')
|
||||
}
|
||||
|
||||
if (!ctx.state.user || !ctx.state.user.email || !ctx.state.user.level) {
|
||||
return ctx.throw(403, 'You must be authenticated to access this resource')
|
||||
}
|
||||
|
||||
if (!ctx.state.atLeast(level)) {
|
||||
return ctx.throw(403, 'You do not have enough access to access this resource')
|
||||
}
|
||||
|
||||
if (next) {
|
||||
return next()
|
||||
}
|
||||
}
|
||||
}
|
275
api/article/model.mjs
Normal file
275
api/article/model.mjs
Normal file
|
@ -0,0 +1,275 @@
|
|||
import { createPrototype, safeColumns } from '../knex.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'
|
||||
|
||||
/*
|
||||
|
||||
Article model:
|
||||
{
|
||||
name,
|
||||
path,
|
||||
description,
|
||||
media_id,
|
||||
staff_id,
|
||||
parent_id,
|
||||
is_deleted,
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
function ArticleItem(data) {
|
||||
Object.assign(this, data)
|
||||
}
|
||||
|
||||
function Article() {
|
||||
this.tableName = 'articles'
|
||||
this.Model = ArticleItem
|
||||
this.includes = {
|
||||
staff: Staff.includeHasOne('articles.staff_id', 'id'),
|
||||
media: Media.includeHasOne('articles.media_id', 'id'),
|
||||
banner: Media.includeHasOne('articles.banner_id', 'id'),
|
||||
parent: Page.includeHasOne('articles.parent_id', 'id'),
|
||||
files: File.includeHasMany('article_id', 'articles.id'),
|
||||
}
|
||||
this.publicFields = this.privateFields = safeColumns([
|
||||
'staff_id',
|
||||
'parent_id',
|
||||
'name',
|
||||
'path',
|
||||
'description',
|
||||
'banner_id',
|
||||
'media_id',
|
||||
'published_at',
|
||||
'is_featured',
|
||||
])
|
||||
this.init()
|
||||
}
|
||||
|
||||
Article.prototype = createPrototype({
|
||||
getAll(ctx, where = null, includes = [], orderBy = 'id', limitToday = false) {
|
||||
return this._getAll(ctx, (qb) => {
|
||||
if (where) qb.where(where)
|
||||
if (limitToday) {
|
||||
qb.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
|
||||
}
|
||||
}, includes, orderBy, [])
|
||||
},
|
||||
|
||||
getAllFromPage(ctx, pageId, includes = [], orderBy = 'id', limitToday = false) {
|
||||
return this._getAll(ctx, (qb) => {
|
||||
qb = qb.innerJoin('pages', 'articles.parent_id', 'pages.id')
|
||||
qb.where(subq => {
|
||||
subq.where('pages.id', pageId)
|
||||
.orWhere('pages.parent_id', pageId)
|
||||
})
|
||||
if (limitToday) {
|
||||
qb.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
|
||||
}
|
||||
return qb
|
||||
}, includes, orderBy, [])
|
||||
},
|
||||
|
||||
getSingle(id, includes = [], require = true, ctx = null, limitToday = false) {
|
||||
return this._getSingle(qb => {
|
||||
qb.where(subq => {
|
||||
subq.where(this.tableName + '.id', '=', Number(id) || 0)
|
||||
.orWhere(this.tableName + '.path', '=', id)
|
||||
})
|
||||
if (limitToday && (!ctx || !ctx.state.user || ctx.state.user.level < 10)) {
|
||||
qb.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
|
||||
}
|
||||
}, includes, require, ctx)
|
||||
},
|
||||
|
||||
getFeaturedArticle(includes = [], ctx = null) {
|
||||
return this._getSingle(qb => {
|
||||
qb.where({ is_featured: true })
|
||||
.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
|
||||
.orderBy(this.tableName + '.published_at', 'DESC')
|
||||
.select(this.knex.raw('1 as __group'))
|
||||
.limit(1)
|
||||
}, includes, false, ctx)
|
||||
},
|
||||
|
||||
async getFrontpageArticles(orgPage = 1) {
|
||||
let page = Math.max(orgPage, 1)
|
||||
let out = {
|
||||
featured: null,
|
||||
items: [],
|
||||
total: 0,
|
||||
}
|
||||
|
||||
let qFeatured = this.query(qb => {
|
||||
return qb.where({ is_featured: true })
|
||||
.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
|
||||
.orderBy(this.tableName + '.published_at', 'DESC')
|
||||
.select(this.knex.raw('1 as __group'))
|
||||
.limit(1)
|
||||
}, ['staff', 'media', 'banner'])
|
||||
let qArticles = this.query(qb => {
|
||||
return qb
|
||||
.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
|
||||
.select(this.knex.raw('2 as __group'))
|
||||
.orderBy(this.tableName + '.published_at', 'DESC')
|
||||
.limit(10)
|
||||
.offset((page - 1) * 10)
|
||||
}, ['staff', 'media', 'banner'], null, qFeatured)
|
||||
|
||||
let [articles, total] = await Promise.all([
|
||||
this.getAllQuery(
|
||||
this.knex
|
||||
.unionAll(qFeatured, true)
|
||||
.unionAll(qArticles, true),
|
||||
qFeatured
|
||||
),
|
||||
this.knex('articles')
|
||||
.where(this.tableName + '.published_at', '<=', (new Date()).toISOString())
|
||||
.where({ is_deleted: false })
|
||||
.count('* as count'),
|
||||
])
|
||||
|
||||
out.total = total[0].count
|
||||
if (articles.length > 0 && articles[0].is_featured) {
|
||||
out.featured = articles[0]
|
||||
out.items = articles.slice(1)
|
||||
} else {
|
||||
out.items = articles
|
||||
}
|
||||
return out
|
||||
},
|
||||
|
||||
setAllUnfeatured() {
|
||||
return knex('articles')
|
||||
.where({ is_featured: true })
|
||||
.update({
|
||||
is_featured: false,
|
||||
})
|
||||
},
|
||||
|
||||
/*parent() {
|
||||
return this.belongsTo(Page, 'parent_id')
|
||||
},
|
||||
|
||||
banner() {
|
||||
return this.belongsTo(Media, 'banner_id')
|
||||
},
|
||||
|
||||
media() {
|
||||
return this.belongsTo(Media, 'media_id')
|
||||
},
|
||||
|
||||
staff() {
|
||||
return this.belongsTo(Staff, 'staff_id')
|
||||
},
|
||||
|
||||
files() {
|
||||
return this.hasManyFiltered(File, 'file', 'article_id')
|
||||
.query(qb => {
|
||||
qb.orderBy('id', 'asc')
|
||||
})
|
||||
},*/
|
||||
|
||||
/*getAll(ctx, where = {}, withRelated = [], orderBy = 'id', limitToday = false) {
|
||||
return this.query(qb => {
|
||||
this.baseQueryAll(ctx, qb, where, orderBy)
|
||||
if (limitToday) {
|
||||
qb.where('published_at', '<=', (new Date()).toISOString())
|
||||
}
|
||||
})
|
||||
.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
|
||||
})
|
||||
},
|
||||
|
||||
getSingle(id, withRelated = [], require = true, ctx = null, limitToday = false) {
|
||||
return this.query(qb => {
|
||||
qb.where(subq => {
|
||||
subq.where({ id: Number(id) || 0 })
|
||||
.orWhere({ path: id })
|
||||
})
|
||||
if (limitToday && (!ctx || !ctx.state.user || ctx.state.user.level < 10)) {
|
||||
qb.where('published_at', '<=', (new Date()).toISOString())
|
||||
}
|
||||
})
|
||||
.fetch({ require, withRelated, ctx })
|
||||
},
|
||||
|
||||
async getFeatured(withRelated = [], ctx = null) {
|
||||
let data = await this.query(qb => {
|
||||
qb.where({ is_featured: true })
|
||||
.where('published_at', '<=', (new Date()).toISOString())
|
||||
})
|
||||
.fetch({ require: false, withRelated, ctx })
|
||||
if (!data) {
|
||||
data = await this.query(qb => {
|
||||
qb.where('published_at', '<=', (new Date()).toISOString())
|
||||
.whereNotNull('banner_id')
|
||||
})
|
||||
.fetch({ require: false, withRelated, ctx })
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
getAllFromPage(ctx, pageId, withRelated = [], orderBy = 'id', limitToday = false) {
|
||||
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)
|
||||
})
|
||||
if (limitToday) {
|
||||
qb.where('published_at', '<=', (new Date()).toISOString())
|
||||
}
|
||||
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
|
||||
})
|
||||
},
|
||||
|
||||
setAllUnfeatured() {
|
||||
return bookshelf.knex('articles')
|
||||
.where({ is_featured: true })
|
||||
.update({
|
||||
is_featured: false,
|
||||
})
|
||||
},
|
||||
|
||||
getFrontpageArticles(page = 1) {
|
||||
return this.query(qb => {
|
||||
qb.orderBy('published_at', 'DESC')
|
||||
.where('published_at', '<=', (new Date()).toISOString())
|
||||
})
|
||||
.fetchPage({
|
||||
pageSize: 10,
|
||||
page: page,
|
||||
withRelated: ['files', 'media', 'banner', 'parent', 'staff'],
|
||||
})
|
||||
},*/
|
||||
})
|
||||
|
||||
const articleInstance = new Article()
|
||||
|
||||
// Hook into includes for Page
|
||||
// Page.addInclude('news', articleInstance.includeHasMany('parent_id', 'pages.id'))
|
||||
|
||||
export default articleInstance
|
77
api/article/routes.mjs
Normal file
77
api/article/routes.mjs
Normal file
|
@ -0,0 +1,77 @@
|
|||
import Article from './model.mjs'
|
||||
import * as security from './security.mjs'
|
||||
|
||||
export default class ArticleRoutes {
|
||||
constructor(opts = {}) {
|
||||
Object.assign(this, {
|
||||
Article: opts.Article || Article,
|
||||
security: opts.security || security,
|
||||
})
|
||||
}
|
||||
|
||||
/** GET: /api/articles */
|
||||
async getAllArticles(ctx) {
|
||||
await this.security.ensureIncludes(ctx)
|
||||
|
||||
ctx.body = await this.Article.getAll(ctx, { }, ctx.state.filter.includes, ctx.query.sort || '-published_at')
|
||||
}
|
||||
|
||||
/** GET: /api/articles/:id */
|
||||
async getSingleArticle(ctx) {
|
||||
await this.security.ensureIncludes(ctx)
|
||||
|
||||
ctx.body = await this.Article.getSingle(ctx.params.id, ctx.state.filter.includes, true, ctx)
|
||||
}
|
||||
|
||||
/** GET: /api/articles/public */
|
||||
async getPublicAllArticles(ctx) {
|
||||
await this.security.ensureIncludes(ctx)
|
||||
|
||||
ctx.body = await this.Article.getAll(ctx, { }, ctx.state.filter.includes, ctx.query.sort || '-published_at', true)
|
||||
}
|
||||
|
||||
/** GET: /api/pages/:pageId/articles/public */
|
||||
async getPublicAllPageArticles(ctx) {
|
||||
await this.security.ensureIncludes(ctx)
|
||||
|
||||
ctx.body = await this.Article.getAllFromPage(ctx, ctx.params.pageId, ctx.state.filter.includes, ctx.query.sort || '-published_at', true)
|
||||
}
|
||||
|
||||
/** GET: /api/articles/public/:id */
|
||||
async getPublicSingleArticle(ctx) {
|
||||
await this.security.ensureIncludes(ctx)
|
||||
|
||||
ctx.body = await this.Article.getSingle(ctx.params.id, ctx.state.filter.includes, true, ctx, true)
|
||||
}
|
||||
|
||||
/** POST: /api/articles */
|
||||
async createArticle(ctx) {
|
||||
await this.security.validUpdate(ctx)
|
||||
|
||||
if (!ctx.request.body.staff_id) {
|
||||
ctx.request.body.staff_id = ctx.state.user.id
|
||||
}
|
||||
|
||||
ctx.body = await this.Article.create(ctx.request.body)
|
||||
}
|
||||
|
||||
/** PUT: /api/articles/:id */
|
||||
async updateArticle(ctx) {
|
||||
await this.security.validUpdate(ctx)
|
||||
|
||||
if (ctx.request.body.is_featured) {
|
||||
await Article.setAllUnfeatured()
|
||||
}
|
||||
|
||||
let article = await this.Article.updateSingle(ctx, ctx.params.id, ctx.request.body)
|
||||
|
||||
ctx.body = article
|
||||
}
|
||||
|
||||
/** DELETE: /api/articles/:id */
|
||||
async removeArticle(ctx) {
|
||||
await this.Article.updateSingle(ctx, ctx.params.id, { is_deleted: true })
|
||||
|
||||
ctx.status = 204
|
||||
}
|
||||
}
|
44
api/article/security.mjs
Normal file
44
api/article/security.mjs
Normal file
|
@ -0,0 +1,44 @@
|
|||
import filter from '../filter.mjs'
|
||||
|
||||
const requiredFields = [
|
||||
'name',
|
||||
'path',
|
||||
]
|
||||
|
||||
const validFields = [
|
||||
'name',
|
||||
'path',
|
||||
'staff_id',
|
||||
'description',
|
||||
'parent_id',
|
||||
'media_id',
|
||||
'banner_id',
|
||||
'published_at',
|
||||
'is_featured',
|
||||
]
|
||||
|
||||
export async function ensureIncludes(ctx) {
|
||||
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(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function validUpdate(ctx) {
|
||||
requiredFields.forEach(item => {
|
||||
if (ctx.request.body[item] == null) {
|
||||
ctx.throw(422, `Property was missing: ${item}`)
|
||||
}
|
||||
})
|
||||
|
||||
let out = filter(Object.keys(ctx.request.body), validFields)
|
||||
|
||||
if (out.length > 0) {
|
||||
ctx.throw(422, `Body had following invalid properties: ${out.join(', ')}`)
|
||||
}
|
||||
|
||||
if (ctx.request.body.published_at) {
|
||||
ctx.request.body.published_at = new Date(ctx.request.body.published_at)
|
||||
}
|
||||
}
|
32
api/authentication/helper.mjs
Normal file
32
api/authentication/helper.mjs
Normal file
|
@ -0,0 +1,32 @@
|
|||
import Staff from '../staff/model.mjs'
|
||||
import Jwt from '../jwt.mjs'
|
||||
|
||||
export default class AuthHelper {
|
||||
constructor(opts = {}) {
|
||||
Object.assign(this, {
|
||||
Staff: opts.Staff || Staff,
|
||||
jwt: opts.jwt || new Jwt(),
|
||||
})
|
||||
}
|
||||
|
||||
async loginStaff(ctx) {
|
||||
let staff
|
||||
|
||||
try {
|
||||
staff = await this.Staff
|
||||
.getSingleQuery(
|
||||
this.Staff.query(qb => qb.where({ email: ctx.request.body.username }), [], ['*']),
|
||||
true
|
||||
)
|
||||
|
||||
await this.Staff.compare(ctx.request.body.password, staff.password)
|
||||
} catch (err) {
|
||||
if (err.message === 'EmptyResponse' || err.message === 'PasswordMismatch') {
|
||||
ctx.throw(422, 'The email or password did not match')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
return this.jwt.createToken(staff.id, staff.email, staff.level)
|
||||
}
|
||||
}
|
32
api/authentication/routes.mjs
Normal file
32
api/authentication/routes.mjs
Normal file
|
@ -0,0 +1,32 @@
|
|||
import Staff from '../staff/model.mjs'
|
||||
import Jwt from '../jwt.mjs'
|
||||
import * as security from './security.mjs'
|
||||
import AuthHelper from './helper.mjs'
|
||||
|
||||
export default class AuthRoutes {
|
||||
constructor(opts = {}) {
|
||||
Object.assign(this, {
|
||||
helper: opts.helper || new AuthHelper(),
|
||||
Staff: opts.Staff || Staff,
|
||||
jwt: opts.jwt || new Jwt(),
|
||||
security: opts.security || security,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* POST /api/login/user - Authenticate a user using password login
|
||||
*
|
||||
* @body {string} username - Username
|
||||
* @body {string} password - Password
|
||||
* @returns
|
||||
*
|
||||
* { token: 'Authentication token' }
|
||||
*/
|
||||
async loginUser(ctx) {
|
||||
this.security.isValidLogin(ctx, ctx.request.body)
|
||||
|
||||
let token = await this.helper.loginStaff(ctx)
|
||||
|
||||
ctx.body = { token }
|
||||
}
|
||||
}
|
18
api/authentication/security.mjs
Normal file
18
api/authentication/security.mjs
Normal file
|
@ -0,0 +1,18 @@
|
|||
|
||||
export function isValidLogin(ctx, body) {
|
||||
if (!body.username) {
|
||||
ctx.throw(422, 'Body was missing property username')
|
||||
}
|
||||
|
||||
if (!body.password) {
|
||||
ctx.throw(422, 'Body was missing property password')
|
||||
}
|
||||
|
||||
if (typeof body.password !== 'string') {
|
||||
ctx.throw(422, 'Property password must be a string')
|
||||
}
|
||||
|
||||
if (typeof body.username !== 'string') {
|
||||
ctx.throw(422, 'Property username must be a string')
|
||||
}
|
||||
}
|
90
api/config.mjs
Normal file
90
api/config.mjs
Normal file
|
@ -0,0 +1,90 @@
|
|||
import _ from 'lodash'
|
||||
import nconf from 'nconf-lite'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
// Helper method for global usage.
|
||||
nconf.inTest = () => nconf.get('NODE_ENV') === 'test'
|
||||
|
||||
// Config follow the following priority check order:
|
||||
// 1. Enviroment variables
|
||||
// 2. package.json
|
||||
// 3. config/config.json
|
||||
// 4. config/config.default.json
|
||||
|
||||
|
||||
|
||||
// Load enviroment variables as first priority
|
||||
nconf.env({
|
||||
separator: '__',
|
||||
whitelist: [
|
||||
'DATABASE_URL',
|
||||
'NODE_ENV',
|
||||
'server__port',
|
||||
'server__host',
|
||||
'knex__connection__host',
|
||||
'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',
|
||||
'sessionsecret',
|
||||
'bcrypt',
|
||||
'name',
|
||||
'NODE_VERSION',
|
||||
],
|
||||
parseValues: true,
|
||||
})
|
||||
|
||||
|
||||
// Load package.json for name and such
|
||||
let pckg = JSON.parse(readFileSync('./package.json'))
|
||||
|
||||
pckg = _.pick(pckg, ['name', 'version', 'description', 'author', 'license', 'homepage'])
|
||||
|
||||
if (nconf.get('DATABASE_URL')) {
|
||||
pckg.knex = { connection: nconf.get('DATABASE_URL') }
|
||||
}
|
||||
|
||||
// Load overrides as second priority
|
||||
nconf.overrides(pckg)
|
||||
|
||||
|
||||
// Load any overrides from the appropriate config file
|
||||
let configFile = 'config/config.json'
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (nconf.get('NODE_ENV') === 'test') {
|
||||
configFile = 'config/config.test.json'
|
||||
}
|
||||
|
||||
/* istanbul ignore if */
|
||||
if (nconf.get('NODE_ENV') === 'production') {
|
||||
configFile = 'config/config.production.json'
|
||||
}
|
||||
|
||||
nconf.file('main', configFile)
|
||||
|
||||
// Load defaults
|
||||
nconf.file('default', 'config/config.default.json')
|
||||
|
||||
|
||||
// Final sanity checks
|
||||
/* istanbul ignore if */
|
||||
if (typeof global.it === 'function' & !nconf.inTest()) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Critical: potentially running test on production enviroment. Shutting down.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
||||
export default nconf
|
34
api/defaults.mjs
Normal file
34
api/defaults.mjs
Normal file
|
@ -0,0 +1,34 @@
|
|||
|
||||
// taken from isobject npm library
|
||||
function isObject(val) {
|
||||
return val != null && typeof val === 'object' && Array.isArray(val) === false
|
||||
}
|
||||
|
||||
export default function defaults(options, def) {
|
||||
let out = { }
|
||||
|
||||
if (options) {
|
||||
Object.keys(options || {}).forEach(key => {
|
||||
out[key] = options[key]
|
||||
|
||||
if (Array.isArray(out[key])) {
|
||||
out[key] = out[key].map(item => {
|
||||
if (isObject(item)) return defaults(item)
|
||||
return item
|
||||
})
|
||||
} else if (out[key] && typeof out[key] === 'object') {
|
||||
out[key] = defaults(options[key], def && def[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (def) {
|
||||
Object.keys(def).forEach(function(key) {
|
||||
if (typeof out[key] === 'undefined') {
|
||||
out[key] = def[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
31
api/error/middleware.mjs
Normal file
31
api/error/middleware.mjs
Normal file
|
@ -0,0 +1,31 @@
|
|||
import createError from 'http-errors'
|
||||
|
||||
export function errorHandler() {
|
||||
return async (ctx, next) => {
|
||||
try {
|
||||
await next()
|
||||
} catch (org) {
|
||||
let error = org
|
||||
if (error.message === 'EmptyResponse') {
|
||||
error = createError(404)
|
||||
} else if (error.message === 'AccessLevelDenied') {
|
||||
error = createError(403)
|
||||
}
|
||||
|
||||
if (!error.status) {
|
||||
ctx.log.error(error)
|
||||
error = createError(500, `Unknown error occured: ${error.message}`)
|
||||
ctx.log.warn(error)
|
||||
} else {
|
||||
ctx.log.warn(error)
|
||||
}
|
||||
|
||||
ctx.status = error.status
|
||||
ctx.body = {
|
||||
status: error.status,
|
||||
message: error.message,
|
||||
body: error.body || { },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
59
api/file/model.mjs
Normal file
59
api/file/model.mjs
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { createPrototype, safeColumns } from '../knex.mjs'
|
||||
import config from '../config.mjs'
|
||||
|
||||
/*
|
||||
|
||||
File model:
|
||||
{
|
||||
filename,
|
||||
filetype,
|
||||
size,
|
||||
path,
|
||||
staff_id,
|
||||
article_id,
|
||||
is_deleted,
|
||||
created_at,
|
||||
updated_at,
|
||||
*url,
|
||||
*magnet,
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
const baseUrl = config.get('upload:baseurl')
|
||||
|
||||
function FileItem(data) {
|
||||
Object.assign(this, data)
|
||||
this.url = `${baseUrl}${this.path}`
|
||||
|
||||
let meta = this.meta
|
||||
if (!meta.torrent) {
|
||||
this.magnet = ''
|
||||
} else {
|
||||
this.magnet = 'magnet:?'
|
||||
+ 'xl=' + this.size
|
||||
+ '&dn=' + encodeURIComponent(meta.torrent.name)
|
||||
+ '&xt=urn:btih:' + meta.torrent.hash
|
||||
+ meta.torrent.announce.map(item => ('&tr=' + encodeURIComponent(item))).join('')
|
||||
}
|
||||
}
|
||||
|
||||
function File() {
|
||||
this.tableName = 'files'
|
||||
this.Model = FileItem
|
||||
this.publicFields = this.privateFields = safeColumns([
|
||||
'article_id',
|
||||
'filename',
|
||||
'filetype',
|
||||
'path',
|
||||
'size',
|
||||
'staff_id',
|
||||
'meta',
|
||||
])
|
||||
this.init()
|
||||
}
|
||||
|
||||
File.prototype = createPrototype({
|
||||
})
|
||||
|
||||
export default new File()
|
61
api/file/routes.mjs
Normal file
61
api/file/routes.mjs
Normal file
|
@ -0,0 +1,61 @@
|
|||
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 = {}) {
|
||||
Object.assign(this, {
|
||||
File: opts.File || File,
|
||||
multer: opts.multer || multer,
|
||||
jwt: opts.jwt || new Jwt(),
|
||||
uploadFile: opts.uploadFile || uploadFile,
|
||||
})
|
||||
}
|
||||
|
||||
async upload(ctx) {
|
||||
let result = await this.multer.processBody(ctx)
|
||||
|
||||
let token = this.jwt.signDirect({ site: config.get('upload:name') }, config.get('upload:secret'))
|
||||
let meta = {}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
async removeFile(ctx) {
|
||||
await this.File.updateSingle(ctx, ctx.params.id, { is_deleted: true })
|
||||
|
||||
ctx.status = 200
|
||||
}
|
||||
}
|
8
api/filter.mjs
Normal file
8
api/filter.mjs
Normal file
|
@ -0,0 +1,8 @@
|
|||
|
||||
export default function filter(input = [], itemFilter = []) {
|
||||
if (input && input.length) {
|
||||
let out = input.filter(item => item && itemFilter.indexOf(item) < 0)
|
||||
return out
|
||||
}
|
||||
return []
|
||||
}
|
9
api/index/routes.mjs
Normal file
9
api/index/routes.mjs
Normal file
|
@ -0,0 +1,9 @@
|
|||
export default class IndexRoutes {
|
||||
constructor(opts = {}) {
|
||||
this.indexBody = ''
|
||||
}
|
||||
|
||||
async sendIndex(ctx) {
|
||||
ctx.body = this.indexBody
|
||||
}
|
||||
}
|
68
api/jwt.mjs
Normal file
68
api/jwt.mjs
Normal file
|
@ -0,0 +1,68 @@
|
|||
import _ from 'lodash'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import koaJwt from 'koa-jwt'
|
||||
import config from './config.mjs'
|
||||
|
||||
export default class Jwt {
|
||||
constructor(opts = {}) {
|
||||
Object.assign(this, {
|
||||
jwt: opts.jwt || jwt,
|
||||
})
|
||||
}
|
||||
|
||||
sign(value, appendSecret = '', opts) {
|
||||
let secret = config.get('jwt:secret') + appendSecret
|
||||
let options = _.defaults(opts, config.get('jwt:options'))
|
||||
|
||||
if (options.expiresIn === null) {
|
||||
delete options.expiresIn
|
||||
}
|
||||
|
||||
return this.jwt.sign(value, secret, options)
|
||||
}
|
||||
|
||||
signDirect(value, secret) {
|
||||
return this.jwt.sign(value, secret)
|
||||
}
|
||||
|
||||
verify(token, appendSecret = '') {
|
||||
let secret = config.get('jwt:secret') + appendSecret
|
||||
|
||||
return new Promise((resolve, reject) =>
|
||||
this.jwt.verify(token, secret, (err, res) => {
|
||||
if (err) return reject(err)
|
||||
|
||||
resolve(res)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
decode(token) {
|
||||
return this.jwt.decode(token)
|
||||
}
|
||||
|
||||
createToken(id, email, level, opts) {
|
||||
return this.sign({
|
||||
id: id,
|
||||
email: email,
|
||||
level: level,
|
||||
}, email, opts)
|
||||
}
|
||||
|
||||
static jwtMiddleware() {
|
||||
return koaJwt({
|
||||
getToken: ctx => {
|
||||
if (ctx.request.header.authorization) {
|
||||
return ctx.request.header.authorization.split(' ')[1]
|
||||
}
|
||||
if (ctx.query.token) {
|
||||
return ctx.query.token
|
||||
}
|
||||
return null
|
||||
},
|
||||
secret: (header, payload) =>
|
||||
`${config.get('jwt:secret')}${payload.email}`,
|
||||
passthrough: true,
|
||||
})
|
||||
}
|
||||
}
|
414
api/knex.mjs
Normal file
414
api/knex.mjs
Normal file
|
@ -0,0 +1,414 @@
|
|||
import _ from 'lodash'
|
||||
import knexCore from 'knex-core'
|
||||
|
||||
import config from './config.mjs'
|
||||
import defaults from './defaults.mjs'
|
||||
import log from './log.mjs'
|
||||
|
||||
const knex = knexCore(config.get('knex'))
|
||||
|
||||
const functionMap = new Map()
|
||||
let joinPostFix = 1
|
||||
|
||||
// Helper method to create models
|
||||
export function createPrototype(opts) {
|
||||
return defaults(opts, {
|
||||
knex: knex,
|
||||
|
||||
init() {
|
||||
if (!this.tableName) throw new Error('createModel was called with missing tableName')
|
||||
if (!this.Model) throw new Error('createModel was called with missing Model')
|
||||
|
||||
if (!this.includes) this.includes = {}
|
||||
if (!this.publicFields) throw new Error(this.tableName + ' was missing publicFields')
|
||||
if (!this.privateFields) throw new Error(this.tableName + ' was missing privateFields')
|
||||
|
||||
this.__includeFields = this.publicFields.map(x => x)
|
||||
|
||||
this.publicFields = this.publicFields.map(x => `${this.tableName}.${x} as ${this.tableName}.${x}`)
|
||||
if (this.publicFields !== this.privateFields) {
|
||||
this.privateFields = this.privateFields.map(x => `${this.tableName}.${x} as ${this.tableName}.${x}`)
|
||||
}
|
||||
},
|
||||
|
||||
addInclude(name, include) {
|
||||
this.includes[name] = include
|
||||
},
|
||||
|
||||
_includeBase(type, subq) {
|
||||
let self = this
|
||||
let postfix = '_' + joinPostFix++
|
||||
let table = this.tableName + postfix
|
||||
return {
|
||||
type: type,
|
||||
postfix: postfix,
|
||||
table: table,
|
||||
fields: this.__includeFields.map(x => `${table}.${x} as ${table}.${x}`),
|
||||
model: self,
|
||||
qb: function(qb) {
|
||||
return subq(self, table, qb)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
includeHasOne(source_id, target_id) {
|
||||
return this._includeBase(1, function(self, table, qb) {
|
||||
return qb.leftOuterJoin(`${self.tableName} as ${table}`, function() {
|
||||
this.on(source_id, '=', table + '.' + target_id)
|
||||
.andOn(table + '.is_deleted', '=', knex.raw('false'))
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
includeHasMany(source_id, target_id, subq = null) {
|
||||
return this._includeBase(2, function(self, table, qb) {
|
||||
return qb.leftOuterJoin(`${self.tableName} as ${table}`, function() {
|
||||
this.on(table + '.' + source_id, '=', target_id)
|
||||
.andOn(table + '.is_deleted', '=', knex.raw('false'))
|
||||
if (subq) {
|
||||
subq(this, self)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async getAllQuery(query, queryContext = null) {
|
||||
let context = (queryContext || query).queryContext()
|
||||
if (!context.tables) throw new Error('getAll was called before query')
|
||||
let tables = context.tables
|
||||
let tableMap = new Map(tables)
|
||||
|
||||
let data = await query
|
||||
|
||||
if (data.length === 0) {
|
||||
return data
|
||||
}
|
||||
|
||||
let keys = Object.keys(data[0])
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
let parts = keys[i].split('.')
|
||||
if (parts.length === 1) {
|
||||
if (parts[0] !== '__group') {
|
||||
tables[0][1].builder += `'${parts[0]}': data.${keys[i]},`
|
||||
}
|
||||
} else {
|
||||
let builder = tableMap.get(parts[0])
|
||||
if (builder) {
|
||||
builder.builder += `'${parts[1]}': data['${keys[i]}'],`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tableMap.forEach(table => {
|
||||
table.builder += '}'
|
||||
table.fn = functionMap.get(table.builder)
|
||||
if (!table.fn) {
|
||||
table.fn = new Function('data', table.builder)
|
||||
functionMap.set(table.builder, table.fn)
|
||||
}
|
||||
})
|
||||
|
||||
let out = []
|
||||
let includesTwoSet = new Set()
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
let baseItem = null
|
||||
for (var t = 0; t < tables.length; t++) {
|
||||
let table = tables[t][1]
|
||||
let propertyName = table.include
|
||||
let formattedData = table.fn(data[i])
|
||||
|
||||
if (!formattedData) {
|
||||
if (propertyName && baseItem[propertyName] === undefined) {
|
||||
console.log('emptying')
|
||||
baseItem[propertyName] = (table.includeType.type === 1 ? null : [])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let row = new table.Model(table.fn(data[i]))
|
||||
let rowId = row.id
|
||||
if (table.isRoot && data[i].__group) {
|
||||
rowId = data[i].__group + '_' + row.id
|
||||
}
|
||||
|
||||
let foundItem = table.map.get(rowId)
|
||||
|
||||
// If we didn't find this item, current table moble or joined table model
|
||||
// is new, therefore we need to create it
|
||||
if (!foundItem) {
|
||||
// Create a reference to it if we're dealing with the root object
|
||||
if (table.isRoot) {
|
||||
baseItem = row
|
||||
}
|
||||
table.map.set(rowId, row)
|
||||
|
||||
if (table.isRoot) {
|
||||
// Add item to root array since this is a root array
|
||||
out.push(baseItem)
|
||||
} else if (table.includeType.type === 1) {
|
||||
// This is a single instance join for the root mode,
|
||||
// set it directly to the root
|
||||
baseItem[propertyName] = row
|
||||
} else if (table.includeType.type === 2) {
|
||||
// This is an array instance for the root model. Time to dig in.
|
||||
/* if (!baseItem[propertyName]) {
|
||||
baseItem[propertyName] = []
|
||||
} */
|
||||
if (!includesTwoSet.has(baseItem.id + '_' + propertyName + '_' + row.id)) {
|
||||
baseItem[propertyName].push(row)
|
||||
includesTwoSet.add(baseItem.id + '_' + propertyName + '_' + row.id)
|
||||
}
|
||||
}
|
||||
} else if (table.isRoot) {
|
||||
baseItem = foundItem
|
||||
} else if (propertyName) {
|
||||
if (table.includeType.type === 1 && !baseItem[propertyName]) {
|
||||
baseItem[propertyName] = foundItem
|
||||
} else if (table.includeType.type === 2 && !includesTwoSet.has(baseItem.id + '_' + propertyName + '_' + row.id)) {
|
||||
/* if (!baseItem[propertyName]) {
|
||||
baseItem[propertyName] = []
|
||||
} */
|
||||
baseItem[propertyName].push(foundItem)
|
||||
includesTwoSet.add(baseItem.id + '_' + propertyName + '_' + row.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
},
|
||||
|
||||
async getSingleQuery(query, require = true) {
|
||||
let data = await this.getAllQuery(query)
|
||||
if (data.length) return data[0]
|
||||
if (require) throw new Error('EmptyResponse')
|
||||
return null
|
||||
},
|
||||
|
||||
query(qb, includes = [], customFields = null, parent = null, pagination = null, paginationOrderBy = null) {
|
||||
let query
|
||||
let fields
|
||||
if (customFields === true) {
|
||||
fields = this.publicFields
|
||||
} else {
|
||||
fields = customFields ? customFields : this.publicFields
|
||||
}
|
||||
if (pagination) {
|
||||
query = knex.with(this.tableName, subq => {
|
||||
subq.select(this.tableName + '.*')
|
||||
.from(this.tableName)
|
||||
.where(this.tableName + '.is_deleted', '=', 'false')
|
||||
|
||||
qb(subq)
|
||||
subq.orderBy(pagination.orderProperty, pagination.sort)
|
||||
.limit(pagination.perPage)
|
||||
.offset((pagination.page - 1) * pagination.perPage)
|
||||
}).from(this.tableName)
|
||||
} else {
|
||||
query = knex(this.tableName).where(this.tableName + '.is_deleted', '=', 'false')
|
||||
qb(query)
|
||||
}
|
||||
let tables = parent && parent.queryContext().tables || []
|
||||
let tableMap = new Map(tables)
|
||||
if (!tables.length) {
|
||||
tables.push([this.tableName, {
|
||||
builder: 'return {',
|
||||
fn: null,
|
||||
map: new Map(),
|
||||
Model: this.Model,
|
||||
isRoot: true,
|
||||
include: null,
|
||||
includeType: {},
|
||||
}])
|
||||
}
|
||||
|
||||
query.select(fields)
|
||||
|
||||
for (let i = 0; i < includes.length; i++) {
|
||||
let includeType = this.includes[includes[i]]
|
||||
if (!includeType) {
|
||||
throw new Error(`Model ${this.tableName} was missing includes ${includes[i]}`)
|
||||
}
|
||||
includeType.qb(query).select(includeType.fields)
|
||||
|
||||
if (tableMap.has(includeType.table)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (includeType.type === 1) {
|
||||
tables[0][1].builder += `${includes[i]}: null,`
|
||||
} else {
|
||||
tables[0][1].builder += `${includes[i]}: [],`
|
||||
}
|
||||
let newTable = [
|
||||
includeType.table,
|
||||
{
|
||||
builder: `if (!data.id && !data['${includeType.table}.id']) {/*console.log('${includeType.table}', data.id, data['${includeType.table}.id']);*/return null;} return {`,
|
||||
fn: null,
|
||||
map: new Map(),
|
||||
isRoot: false,
|
||||
Model: includeType.model.Model,
|
||||
include: includes[i],
|
||||
includeType: includeType,
|
||||
}
|
||||
]
|
||||
tables.push(newTable)
|
||||
tableMap.set(newTable[0], newTable[1])
|
||||
}
|
||||
|
||||
if (pagination) {
|
||||
query.orderBy(pagination.orderProperty, pagination.sort)
|
||||
}
|
||||
|
||||
query.queryContext({ tables: tables })
|
||||
|
||||
return query
|
||||
},
|
||||
|
||||
async _getAll(ctx, subq, includes = [], orderBy = 'id') {
|
||||
let orderProperty = orderBy
|
||||
let sort = 'ASC'
|
||||
|
||||
if (orderProperty[0] === '-') {
|
||||
orderProperty = orderProperty.slice(1)
|
||||
sort = 'DESC'
|
||||
}
|
||||
|
||||
ctx.state.pagination.sort = sort
|
||||
ctx.state.pagination.orderProperty = orderProperty
|
||||
|
||||
let [data, total] = await Promise.all([
|
||||
this.getAllQuery(this.query(qb => {
|
||||
let qbnow = qb
|
||||
if (subq) {
|
||||
qbnow = subq(qb) || qb
|
||||
}
|
||||
return qbnow
|
||||
}, includes, null, null, ctx.state.pagination)),
|
||||
(() => {
|
||||
let qb = this.knex(this.tableName)
|
||||
if (subq) {
|
||||
qb = subq(qb) || qb
|
||||
}
|
||||
qb.where(this.tableName + '.is_deleted', '=', false)
|
||||
return qb.count('* as count')
|
||||
})(),
|
||||
])
|
||||
ctx.state.pagination.total = total[0].count
|
||||
return data
|
||||
},
|
||||
|
||||
getAll(ctx, subq, includes = [], orderBy = 'id') {
|
||||
return this._getAll(ctx, subq, includes, orderBy)
|
||||
},
|
||||
|
||||
_getSingle(subq, includes = [], require = true, ctx = null) {
|
||||
return this.getSingleQuery(this.query(qb => {
|
||||
return qb
|
||||
.where(qb => {
|
||||
if (subq) subq(qb)
|
||||
})
|
||||
}, includes), require)
|
||||
},
|
||||
|
||||
getSingle(id, includes = [], require = true, ctx = null) {
|
||||
return this._getSingle(qb => qb.where(this.tableName + '.id', '=', Number(id) || 0 ), includes, require, ctx)
|
||||
},
|
||||
|
||||
async updateSingle(ctx, id, body) {
|
||||
// Fetch the item in question, making sure it exists
|
||||
let item = await this.getSingle(id, [], true, ctx)
|
||||
|
||||
// Paranoia checking
|
||||
if (typeof(item.id) !== 'number') throw new Error('Item was missing id')
|
||||
|
||||
body.updated_at = new Date()
|
||||
|
||||
// Update our item in the database
|
||||
let out = await knex(this.tableName)
|
||||
.where({ id: item.id })
|
||||
// Map out the 'as' from the private fields so it returns a clean
|
||||
// response in the body
|
||||
.update(body, this.privateFields.map(x => x.split('as')[0]))
|
||||
|
||||
// More paranoia checking
|
||||
if (out.length < 1) throw new Error('Updated item returned empty result')
|
||||
|
||||
return out[0]
|
||||
},
|
||||
|
||||
/**
|
||||
* Create new entry in the database.
|
||||
*
|
||||
* @param {Object} data - The values the new item should have
|
||||
* @return {Object} The resulting object
|
||||
*/
|
||||
async create(body) {
|
||||
body.created_at = new Date()
|
||||
body.updated_at = new Date()
|
||||
let out = await knex(this.tableName)
|
||||
// Map out the 'as' from the private fields so it returns a clean
|
||||
// response in the body
|
||||
.insert(body, this.privateFields.map(x => x.split('as')[0]))
|
||||
|
||||
// More paranoia checking
|
||||
if (out.length < 1) throw new Error('Updated item returned empty result')
|
||||
|
||||
return out[0]
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply basic filtering to query builder object. Basic filtering
|
||||
* applies stuff like custom filtering in the query and ordering and other stuff
|
||||
*
|
||||
* @param {Request} ctx - API Request object
|
||||
* @param {QueryBuilder} qb - knex query builder object to apply filtering on
|
||||
* @param {Object} [where={}] - Any additional filtering
|
||||
* @param {string} [orderBy=id] - property to order result by
|
||||
* @param {Object[]} [properties=[]] - Properties allowed to filter by from query
|
||||
*/
|
||||
_baseQueryAll(ctx, qb, where = {}, orderBy = 'id', properties = []) {
|
||||
let orderProperty = orderBy
|
||||
let sort = 'ASC'
|
||||
|
||||
if (orderProperty[0] === '-') {
|
||||
orderProperty = orderProperty.slice(1)
|
||||
sort = 'DESC'
|
||||
}
|
||||
|
||||
qb.where(where)
|
||||
_.forOwn(ctx.state.filter.where(properties), (value, key) => {
|
||||
if (key.startsWith('is_')) {
|
||||
qb.where(key, value === '0' ? false : true)
|
||||
} else {
|
||||
qb.where(key, 'LIKE', `%${value}%`)
|
||||
}
|
||||
})
|
||||
_.forOwn(ctx.state.filter.whereNot(properties), (value, key) => {
|
||||
if (key.startsWith('is_')) {
|
||||
qb.whereNot(key, value === '0' ? false : true)
|
||||
} else {
|
||||
qb.where(key, 'NOT LIKE', `%${value}%`)
|
||||
}
|
||||
})
|
||||
qb.orderBy(orderProperty, sort)
|
||||
},
|
||||
|
||||
/*async getSingle(id, require = true, ctx = null) {
|
||||
let where = { id: Number(id) || 0 }
|
||||
|
||||
let data = await knex(this.tableName).where(where).first(this.publicFields)
|
||||
|
||||
if (!data && require) throw new Error('EmptyResponse')
|
||||
|
||||
return data
|
||||
},*/
|
||||
})
|
||||
}
|
||||
|
||||
export function safeColumns(extra) {
|
||||
return ['id', /*'is_deleted',*/ 'created_at', 'updated_at'].concat(extra || [])
|
||||
}
|
||||
/*shelf.safeColumns = (extra) =>
|
||||
['id', 'is_deleted', 'created_at', 'updated_at'].concat(extra || [])*/
|
28
api/log.mjs
Normal file
28
api/log.mjs
Normal file
|
@ -0,0 +1,28 @@
|
|||
import bunyan from 'bunyan-lite'
|
||||
import config from './config.mjs'
|
||||
import * as defaults from './defaults.mjs'
|
||||
|
||||
// Clone the settings as we will be touching
|
||||
// on them slightly.
|
||||
let settings = defaults.default(config.get('bunyan'))
|
||||
|
||||
// Replace any instance of 'process.stdout' with the
|
||||
// actual reference to the process.stdout.
|
||||
for (let i = 0; i < settings.streams.length; i++) {
|
||||
/* istanbul ignore else */
|
||||
if (settings.streams[i].stream === 'process.stdout') {
|
||||
settings.streams[i].stream = process.stdout
|
||||
}
|
||||
}
|
||||
|
||||
// Create our logger.
|
||||
const log = bunyan.createLogger(settings)
|
||||
|
||||
export default log
|
||||
|
||||
log.logMiddleware = () =>
|
||||
(ctx, next) => {
|
||||
ctx.log = log
|
||||
|
||||
return next()
|
||||
}
|
133
api/media/model.mjs
Normal file
133
api/media/model.mjs
Normal file
|
@ -0,0 +1,133 @@
|
|||
import path from 'path'
|
||||
import { createPrototype, safeColumns } from '../knex.mjs'
|
||||
import config from '../config.mjs'
|
||||
|
||||
/*
|
||||
|
||||
Media model:
|
||||
{
|
||||
filename,
|
||||
filetype,
|
||||
small_image,
|
||||
medium_image,
|
||||
large_image,
|
||||
*small_url,
|
||||
*medium_url,
|
||||
*large_url,
|
||||
size,
|
||||
staff_id,
|
||||
is_deleted,
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
const baseUrl = config.get('upload:baseurl')
|
||||
|
||||
function MediaItem(data) {
|
||||
Object.assign(this, data)
|
||||
|
||||
this.small_url = `${baseUrl}${this.small_image}`
|
||||
this.medium_url = `${baseUrl}${this.medium_image}`
|
||||
this.large_url = `${baseUrl}${this.large_image}`
|
||||
this.small_url_avif = this.small_image_avif ? `${baseUrl}${this.small_image_avif}` : null
|
||||
this.medium_url_avif = this.small_image_avif ? `${baseUrl}${this.medium_image_avif}` : null
|
||||
this.large_url_avif = this.small_image_avif ? `${baseUrl}${this.large_image_avif}` : null
|
||||
this.link = `${baseUrl}${this.org_image}`
|
||||
}
|
||||
|
||||
function Media() {
|
||||
this.tableName = 'media'
|
||||
this.Model = MediaItem
|
||||
this.publicFields = this.privateFields = safeColumns([
|
||||
'filename',
|
||||
'filetype',
|
||||
'small_image',
|
||||
'medium_image',
|
||||
'large_image',
|
||||
'org_image',
|
||||
'size',
|
||||
'staff_id',
|
||||
'small_image_avif',
|
||||
'medium_image_avif',
|
||||
'large_image_avif',
|
||||
])
|
||||
this.init()
|
||||
}
|
||||
|
||||
Media.prototype = createPrototype({
|
||||
baseUrl: baseUrl,
|
||||
|
||||
getSubUrl(input, size, type = 'jpg') {
|
||||
if (!input) return input
|
||||
|
||||
let output = input
|
||||
if (path.extname(input)) {
|
||||
let ext = path.extname(input).toLowerCase()
|
||||
output = input.slice(0, -ext.length)
|
||||
}
|
||||
return `${output}.${size}.${type}`
|
||||
},
|
||||
})
|
||||
|
||||
/*
|
||||
const Media = bookshelf.createModel({
|
||||
tableName: 'media',
|
||||
|
||||
virtuals: {
|
||||
small_url() {
|
||||
return `${Media.baseUrl}${this.get('small_image')}`
|
||||
},
|
||||
|
||||
medium_url() {
|
||||
return `${Media.baseUrl}${this.get('medium_image')}`
|
||||
},
|
||||
|
||||
large_url() {
|
||||
return `${Media.baseUrl}${this.get('large_image')}`
|
||||
},
|
||||
|
||||
small_url_avif() {
|
||||
if (!this.get('small_image_avif')) return null
|
||||
return `${Media.baseUrl}${this.get('small_image_avif')}`
|
||||
},
|
||||
|
||||
medium_url_avif() {
|
||||
if (!this.get('small_image_avif')) return null
|
||||
return `${Media.baseUrl}${this.get('medium_image_avif')}`
|
||||
},
|
||||
|
||||
large_url_avif() {
|
||||
if (!this.get('small_image_avif')) return null
|
||||
return `${Media.baseUrl}${this.get('large_image_avif')}`
|
||||
},
|
||||
|
||||
link() {
|
||||
return `${Media.baseUrl}${this.get('org_image')}`
|
||||
},
|
||||
|
||||
url() {
|
||||
return `${Media.baseUrl}${this.get('medium_image')}`
|
||||
},
|
||||
|
||||
thumb() {
|
||||
return `${Media.baseUrl}${this.get('small_image')}`
|
||||
},
|
||||
},
|
||||
}, {
|
||||
baseUrl: config.get('upload:baseurl'),
|
||||
|
||||
getSubUrl(input, size, type = 'jpg') {
|
||||
if (!input) return input
|
||||
|
||||
let output = input
|
||||
if (path.extname(input)) {
|
||||
let ext = path.extname(input).toLowerCase()
|
||||
output = input.slice(0, -ext.length)
|
||||
}
|
||||
return `${output}.${size}.${type}`
|
||||
},
|
||||
})*/
|
||||
|
||||
export default new Media()
|
101
api/media/resize.mjs
Normal file
101
api/media/resize.mjs
Normal file
|
@ -0,0 +1,101 @@
|
|||
import sharp from 'sharp'
|
||||
import Media from './model.mjs'
|
||||
|
||||
export default class Resizer {
|
||||
constructor(opts = {}) {
|
||||
Object.assign(this, {
|
||||
Media: opts.Media || Media,
|
||||
sharp: opts.sharp || sharp,
|
||||
})
|
||||
}
|
||||
|
||||
createSmall(input, format = 'jpg') {
|
||||
let output = this.Media.getSubUrl(input, 'small', format === 'avif' ? 'avif' : 'jpg')
|
||||
|
||||
let sized = this.sharp(input)
|
||||
.resize(500, 500, {
|
||||
fit: sharp.fit.inside,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
|
||||
if (format === 'avif') {
|
||||
return sized
|
||||
.avif({
|
||||
quality: 80,
|
||||
speed: 5,
|
||||
})
|
||||
.toFile(output)
|
||||
.then(() => output)
|
||||
} else {
|
||||
return sized
|
||||
.jpeg({
|
||||
quality: 93,
|
||||
})
|
||||
.toFile(output)
|
||||
.then(() => output)
|
||||
}
|
||||
}
|
||||
|
||||
createMedium(input, height, format = 'jpg') {
|
||||
let output = this.Media.getSubUrl(input, 'medium', format === 'avif' ? 'avif' : 'jpg')
|
||||
|
||||
let sized = this.sharp(input)
|
||||
.resize(800, height || 800, {
|
||||
fit: height && sharp.fit.cover || sharp.fit.inside,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
|
||||
if (format === 'avif') {
|
||||
return sized
|
||||
.avif({
|
||||
quality: 80,
|
||||
speed: 5,
|
||||
})
|
||||
.toFile(output)
|
||||
.then(() => output)
|
||||
} else {
|
||||
return sized
|
||||
.jpeg({
|
||||
quality: 93,
|
||||
})
|
||||
.toFile(output)
|
||||
.then(() => output)
|
||||
}
|
||||
}
|
||||
|
||||
createLarge(input, format = 'jpg') {
|
||||
let output = this.Media.getSubUrl(input, 'large', format === 'avif' ? 'avif' : 'jpg')
|
||||
|
||||
let sized = this.sharp(input)
|
||||
.resize(1280, 1280, {
|
||||
fit: sharp.fit.inside,
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
|
||||
if (format === 'avif') {
|
||||
return sized
|
||||
.avif({
|
||||
quality: 85,
|
||||
speed: 5,
|
||||
})
|
||||
.toFile(output)
|
||||
.then(() => output)
|
||||
} else {
|
||||
return sized
|
||||
.jpeg({
|
||||
quality: 93,
|
||||
})
|
||||
.toFile(output)
|
||||
.then(() => output)
|
||||
}
|
||||
}
|
||||
|
||||
autoRotate(input) {
|
||||
const output = `${input}_2.jpg`
|
||||
|
||||
return this.sharp(input)
|
||||
.rotate()
|
||||
.toFile(output)
|
||||
.then(() => output)
|
||||
}
|
||||
}
|
70
api/media/routes.mjs
Normal file
70
api/media/routes.mjs
Normal file
|
@ -0,0 +1,70 @@
|
|||
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 = {}) {
|
||||
Object.assign(this, {
|
||||
Media: opts.Media || Media,
|
||||
multer: opts.multer || multer,
|
||||
resize: opts.resize || new Resizer(),
|
||||
jwt: opts.jwt || new Jwt(),
|
||||
uploadFile: opts.uploadFile || uploadFile,
|
||||
})
|
||||
}
|
||||
|
||||
async upload(ctx) {
|
||||
let result = await this.multer.processBody(ctx)
|
||||
|
||||
let height = null
|
||||
if (ctx.query.height) {
|
||||
height = Number(ctx.query.height)
|
||||
}
|
||||
|
||||
let smallPath = await this.resize.createSmall(result.path)
|
||||
let mediumPath = await this.resize.createMedium(result.path, height)
|
||||
let largePath = await this.resize.createLarge(result.path)
|
||||
let smallPathAvif = await this.resize.createSmall(result.path, 'avif')
|
||||
let mediumPathAvif = await this.resize.createMedium(result.path, height, 'avif')
|
||||
let largePathAvif = await this.resize.createLarge(result.path, 'avif')
|
||||
|
||||
let token = this.jwt.signDirect({ site: config.get('upload:name') }, config.get('upload:secret'))
|
||||
|
||||
let [org, small, medium, large, smallAvif, mediumAvif, largeAvif] = await Promise.all([
|
||||
this.uploadFile(token, result.path),
|
||||
this.uploadFile(token, smallPath),
|
||||
this.uploadFile(token, mediumPath),
|
||||
this.uploadFile(token, largePath),
|
||||
this.uploadFile(token, smallPathAvif),
|
||||
this.uploadFile(token, mediumPathAvif),
|
||||
this.uploadFile(token, largePathAvif),
|
||||
])
|
||||
|
||||
ctx.body = await this.Media.create({
|
||||
filename: result.originalname,
|
||||
filetype: result.mimetype,
|
||||
small_image: small.path,
|
||||
medium_image: medium.path,
|
||||
large_image: large.path,
|
||||
small_image_avif: smallAvif.path,
|
||||
medium_image_avif: mediumAvif.path,
|
||||
large_image_avif: largeAvif.path,
|
||||
org_image: org.path,
|
||||
size: result.size,
|
||||
staff_id: ctx.state.user.id,
|
||||
})
|
||||
}
|
||||
|
||||
async getAllMedia(ctx) {
|
||||
ctx.body = await this.Media.getAll(ctx)
|
||||
}
|
||||
|
||||
async removeMedia(ctx) {
|
||||
await this.Media.updateSingle(ctx, ctx.params.id, { is_deleted: true })
|
||||
|
||||
ctx.status = 200
|
||||
}
|
||||
}
|
77
api/media/upload.mjs
Normal file
77
api/media/upload.mjs
Normal file
|
@ -0,0 +1,77 @@
|
|||
import http from 'http'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import config from '../config.mjs'
|
||||
|
||||
let stub
|
||||
|
||||
export function uploadFile(token, file) {
|
||||
// For testing
|
||||
if (stub) return stub(token, file)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) return reject(err)
|
||||
|
||||
const crlf = '\r\n'
|
||||
const filename = path.basename(file)
|
||||
const boundary = `--${Math.random().toString(16)}`
|
||||
const headers = [
|
||||
`Content-Disposition: form-data; name="file"; filename="${filename}"` + crlf,
|
||||
]
|
||||
const multipartBody = Buffer.concat([
|
||||
new Buffer(
|
||||
`${crlf}--${boundary}${crlf}` +
|
||||
headers.join('') + crlf
|
||||
),
|
||||
data,
|
||||
new Buffer(
|
||||
`${crlf}--${boundary}--`
|
||||
),
|
||||
])
|
||||
|
||||
const options = {
|
||||
port: config.get('upload:port'),
|
||||
hostname: config.get('upload:host'),
|
||||
method: 'POST',
|
||||
path: '/media?token=' + token,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data; boundary=' + boundary,
|
||||
'Content-Length': multipartBody.length,
|
||||
},
|
||||
}
|
||||
|
||||
const req = http.request(options)
|
||||
|
||||
req.write(multipartBody)
|
||||
req.end()
|
||||
|
||||
req.on('error', reject)
|
||||
|
||||
req.on('response', res => {
|
||||
res.setEncoding('utf8')
|
||||
let output = ''
|
||||
|
||||
res.on('data', function (chunk) {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
res.on('end', function () {
|
||||
try {
|
||||
output = JSON.parse(output)
|
||||
} catch (e) {
|
||||
return reject(e)
|
||||
}
|
||||
if (output.status) {
|
||||
return reject(new Error(`Unable to upload! [${output.status}]: ${output.message}`))
|
||||
}
|
||||
resolve(output)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function overrideStub(newStub) {
|
||||
stub = newStub
|
||||
}
|
18
api/middlewares/mask.mjs
Normal file
18
api/middlewares/mask.mjs
Normal file
|
@ -0,0 +1,18 @@
|
|||
import jsonmask from 'json-mask'
|
||||
|
||||
export function mask() {
|
||||
return async function(ctx, next) {
|
||||
await next()
|
||||
|
||||
let body = ctx.body
|
||||
let fields = ctx.query['fields'] || ctx.fields
|
||||
|
||||
if (!body || 'object' != typeof body || !fields) return
|
||||
|
||||
if (body && body.toJSON) {
|
||||
body = body.toJSON()
|
||||
}
|
||||
|
||||
ctx.body = jsonmask.filter(body, jsonmask.compile(fields))
|
||||
}
|
||||
}
|
15
api/multer.mjs
Normal file
15
api/multer.mjs
Normal file
|
@ -0,0 +1,15 @@
|
|||
import multer from 'multer'
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
filename: (req, file, cb) => cb(null, file.originalname),
|
||||
})
|
||||
const upload = multer({ storage: storage })
|
||||
|
||||
export function processBody(ctx) {
|
||||
return new Promise((res, rej) => {
|
||||
upload.single('file')(ctx.req, ctx.res, (err) => {
|
||||
if (err) return rej(err)
|
||||
return res(ctx.req.file)
|
||||
})
|
||||
})
|
||||
}
|
125
api/page/model.mjs
Normal file
125
api/page/model.mjs
Normal file
|
@ -0,0 +1,125 @@
|
|||
|
||||
import { createPrototype, safeColumns } from '../knex.mjs'
|
||||
import Media from '../media/model.mjs'
|
||||
// import Staff from '../staff/model.mjs'
|
||||
// import Article from '../article/model.mjs'
|
||||
|
||||
/*
|
||||
|
||||
Page model:
|
||||
{
|
||||
filename,
|
||||
filetype,
|
||||
small_image,
|
||||
medium_image,
|
||||
large_image,
|
||||
*small_url,
|
||||
*medium_url,
|
||||
*large_url,
|
||||
size,
|
||||
staff_id,
|
||||
is_deleted,
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
function PageItem(data) {
|
||||
Object.assign(this, data)
|
||||
this.children = []
|
||||
}
|
||||
|
||||
function Page() {
|
||||
this.tableName = 'pages'
|
||||
this.Model = PageItem
|
||||
this.includes = {
|
||||
media: Media.includeHasOne('pages.media_id', 'id'),
|
||||
banner: Media.includeHasOne('pages.banner_id', 'id'),
|
||||
}
|
||||
this.publicFields = this.privateFields = safeColumns([
|
||||
'staff_id',
|
||||
'parent_id',
|
||||
'name',
|
||||
'path',
|
||||
'description',
|
||||
'banner_id',
|
||||
'media_id',
|
||||
])
|
||||
this.init()
|
||||
}
|
||||
|
||||
Page.prototype = createPrototype({
|
||||
/* includes: {
|
||||
staff: Staff.includeHasOne('staff_id', 'id'),
|
||||
}, */
|
||||
|
||||
/*banner() {
|
||||
return this.belongsTo(Media, 'banner_id')
|
||||
},
|
||||
|
||||
parent() {
|
||||
return this.belongsTo(Page, 'parent_id')
|
||||
},
|
||||
|
||||
children() {
|
||||
return this.hasManyFiltered(Page, 'children', 'parent_id')
|
||||
.query(qb => {
|
||||
qb.orderBy('name', 'ASC')
|
||||
})
|
||||
},
|
||||
|
||||
news() {
|
||||
return this.hasManyFiltered(Article, 'news', 'parent_id')
|
||||
.query(qb => {
|
||||
qb.orderBy('id', 'desc')
|
||||
})
|
||||
},
|
||||
|
||||
media() {
|
||||
return this.belongsTo(Media, 'media_id')
|
||||
},
|
||||
|
||||
staff() {
|
||||
return this.belongsTo(Staff, 'staff_id')
|
||||
},*/
|
||||
|
||||
getSingle(id, includes = [], require = true, ctx = null) {
|
||||
return this._getSingle(qb => {
|
||||
qb.where(subq => {
|
||||
subq.where(this.tableName + '.id', '=', Number(id) || 0)
|
||||
.orWhere(this.tableName + '.path', '=', id)
|
||||
})
|
||||
}, includes, require, ctx)
|
||||
},
|
||||
|
||||
async getTree() {
|
||||
let items = await this.getAllQuery(this.query(
|
||||
qb => qb.orderBy('name', 'ASC'),
|
||||
[],
|
||||
['parent_id', 'id', 'name', 'path']
|
||||
))
|
||||
|
||||
let out = []
|
||||
let map = new Map()
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (!items[i].parent_id) {
|
||||
out.push(items[i])
|
||||
}
|
||||
map.set(items[i].id, items[i])
|
||||
}
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].parent_id && map.has(items[i].parent_id)) {
|
||||
map.get(items[i].parent_id).children.push(items[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
},
|
||||
})
|
||||
|
||||
const pageInstance = new Page()
|
||||
|
||||
pageInstance.addInclude('children', pageInstance.includeHasMany('parent_id', 'pages.id'))
|
||||
pageInstance.addInclude('parent', pageInstance.includeHasOne('pages.parent_id', 'id'))
|
||||
|
||||
export default pageInstance
|
53
api/page/routes.mjs
Normal file
53
api/page/routes.mjs
Normal file
|
@ -0,0 +1,53 @@
|
|||
import Page from './model.mjs'
|
||||
import * as security from './security.mjs'
|
||||
|
||||
export default class PageRoutes {
|
||||
constructor(opts = {}) {
|
||||
Object.assign(this, {
|
||||
Page: opts.Page || Page,
|
||||
security: opts.security || security,
|
||||
})
|
||||
}
|
||||
|
||||
/** GET: /api/pagetree */
|
||||
async getPageTree(ctx) {
|
||||
ctx.body = await this.Page.getTree()
|
||||
}
|
||||
|
||||
/** GET: /api/pages */
|
||||
async getAllPages(ctx) {
|
||||
await this.security.ensureIncludes(ctx)
|
||||
|
||||
ctx.body = await this.Page.getAll(ctx, null, ctx.state.filter.includes, 'name')
|
||||
}
|
||||
|
||||
/** GET: /api/pages/:id */
|
||||
async getSinglePage(ctx) {
|
||||
await this.security.ensureIncludes(ctx)
|
||||
|
||||
ctx.body = await this.Page.getSingle(ctx.params.id, ctx.state.filter.includes, true, ctx)
|
||||
}
|
||||
|
||||
/** POST: /api/pages */
|
||||
async createPage(ctx) {
|
||||
await this.security.validUpdate(ctx)
|
||||
|
||||
ctx.body = await this.Page.create(ctx.request.body)
|
||||
}
|
||||
|
||||
/** PUT: /api/pages/:id */
|
||||
async updatePage(ctx) {
|
||||
await this.security.validUpdate(ctx)
|
||||
|
||||
let page = await this.Page.updateSingle(ctx, ctx.params.id, ctx.request.body)
|
||||
|
||||
ctx.body = page
|
||||
}
|
||||
|
||||
/** DELETE: /api/pages/:id */
|
||||
async removePage(ctx) {
|
||||
await this.Page.updateSingle(ctx, ctx.params.id, { is_deleted: true })
|
||||
|
||||
ctx.status = 204
|
||||
}
|
||||
}
|
37
api/page/security.mjs
Normal file
37
api/page/security.mjs
Normal file
|
@ -0,0 +1,37 @@
|
|||
import filter from '../filter.mjs'
|
||||
|
||||
const requiredFields = [
|
||||
'name',
|
||||
'path',
|
||||
]
|
||||
|
||||
const validFields = [
|
||||
'name',
|
||||
'path',
|
||||
'parent_id',
|
||||
'description',
|
||||
'media_id',
|
||||
'banner_id',
|
||||
]
|
||||
|
||||
export async function ensureIncludes(ctx) {
|
||||
let out = filter(ctx.state.filter.includes, ['staff', 'media', 'banner', 'news', 'news.media', 'parent', 'children'])
|
||||
|
||||
if (out.length > 0) {
|
||||
ctx.throw(422, `Includes had following invalid values: ${out.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function validUpdate(ctx) {
|
||||
requiredFields.forEach(item => {
|
||||
if (ctx.request.body[item] == null) {
|
||||
ctx.throw(422, `Property was missing: ${item}`)
|
||||
}
|
||||
})
|
||||
|
||||
let out = filter(Object.keys(ctx.request.body), validFields)
|
||||
|
||||
if (out.length > 0) {
|
||||
ctx.throw(422, `Body had following invalid properties: ${out.join(', ')}`)
|
||||
}
|
||||
}
|
35
api/parser/middleware.mjs
Normal file
35
api/parser/middleware.mjs
Normal file
|
@ -0,0 +1,35 @@
|
|||
import format from 'format-link-header'
|
||||
|
||||
import * as pagination from './pagination.mjs'
|
||||
|
||||
export default class ParserMiddleware {
|
||||
constructor(opts = {}) {
|
||||
Object.assign(this, {
|
||||
pagination: opts.pagination || pagination,
|
||||
format: opts.format || format,
|
||||
})
|
||||
}
|
||||
|
||||
contextParser() {
|
||||
return (ctx, next) => {
|
||||
ctx.state.pagination = this.pagination.parsePagination(ctx)
|
||||
ctx.state.filter = this.pagination.parseFilter(ctx)
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
generateLinks() {
|
||||
return async (ctx, next) => {
|
||||
await next()
|
||||
|
||||
if (ctx.state.pagination.total > 0) {
|
||||
ctx.set('Link', this.format(this.pagination.generateLinks(ctx, ctx.state.pagination.total)))
|
||||
}
|
||||
|
||||
if (ctx.state.pagination.total != null) {
|
||||
ctx.set('pagination_total', ctx.state.pagination.total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
129
api/parser/pagination.mjs
Normal file
129
api/parser/pagination.mjs
Normal file
|
@ -0,0 +1,129 @@
|
|||
import _ from 'lodash'
|
||||
import { format } from 'url'
|
||||
import config from '../config.mjs'
|
||||
|
||||
function limit(value, min, max, fallback) {
|
||||
let out = parseInt(value, 10)
|
||||
|
||||
if (!out) {
|
||||
out = fallback
|
||||
}
|
||||
|
||||
if (out < min) {
|
||||
out = min
|
||||
}
|
||||
|
||||
if (out > max) {
|
||||
out = max
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function parsePagination(ctx) {
|
||||
let out = {
|
||||
perPage: limit(ctx.query.perPage, 1, 1500, 1250),
|
||||
page: limit(ctx.query.page, 1, Number.MAX_SAFE_INTEGER, 1),
|
||||
}
|
||||
|
||||
Object.keys(ctx.query).forEach(item => {
|
||||
if (item.startsWith('perPage.')) {
|
||||
let name = item.substring(8)
|
||||
out[name] = {
|
||||
perPage: limit(ctx.query[`perPage.${name}`], 1, 1500, 1250),
|
||||
page: limit(ctx.query[`page.${name}`], 1, Number.MAX_SAFE_INTEGER, 1),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function parseFilter(ctx) {
|
||||
let where
|
||||
let whereNot
|
||||
|
||||
where = _.omitBy(ctx.query, test => test[0] === '!')
|
||||
|
||||
whereNot = _.pickBy(ctx.query, test => test[0] === '!')
|
||||
whereNot = _.transform(
|
||||
whereNot,
|
||||
(result, value, key) => (result[key] = value.slice(1))
|
||||
)
|
||||
|
||||
return {
|
||||
where: pick => _.pick(where, pick),
|
||||
whereNot: pick => _.pick(whereNot, pick),
|
||||
includes: (ctx.query.includes && ctx.query.includes.split(',')) || [],
|
||||
}
|
||||
}
|
||||
|
||||
export function generateLinks(ctx, total) {
|
||||
let out = []
|
||||
|
||||
let base = _(ctx.query)
|
||||
.omit(['page'])
|
||||
.transform((res, val, key) => res.push(`${key}=${val}`), [])
|
||||
.value()
|
||||
|
||||
if (!ctx.query.perPage) {
|
||||
base.push(`perPage=${ctx.state.pagination.perPage}`)
|
||||
}
|
||||
|
||||
// let protocol = ctx.protocol
|
||||
|
||||
// if (config.get('frontend:url').startsWith('https')) {
|
||||
// protocol = 'https'
|
||||
// }
|
||||
|
||||
let proto = ctx.protocol
|
||||
|
||||
if (config.get('frontend:url').startsWith('https')) {
|
||||
proto = 'https'
|
||||
}
|
||||
|
||||
let first = format({
|
||||
protocol: proto,
|
||||
host: ctx.host,
|
||||
pathname: ctx.path,
|
||||
})
|
||||
|
||||
first += `?${base.join('&')}`
|
||||
|
||||
// Add the current page first
|
||||
out.push({
|
||||
rel: 'current',
|
||||
title: `Page ${ctx.query.page || 1}`,
|
||||
url: `${first}`,
|
||||
})
|
||||
|
||||
// Then add any previous pages if we can
|
||||
if (ctx.state.pagination.page > 1) {
|
||||
out.push({
|
||||
rel: 'previous',
|
||||
title: 'Previous',
|
||||
url: `${first}&page=${ctx.state.pagination.page - 1}`,
|
||||
})
|
||||
out.push({
|
||||
rel: 'first',
|
||||
title: 'First',
|
||||
url: `${first}&page=1`,
|
||||
})
|
||||
}
|
||||
|
||||
// Then add any next pages if we can
|
||||
if ((ctx.state.pagination.perPage * (ctx.state.pagination.page - 1)) + ctx.state.pagination.perPage < total) {
|
||||
out.push({
|
||||
rel: 'next',
|
||||
title: 'Next',
|
||||
url: `${first}&page=${ctx.state.pagination.page + 1}`,
|
||||
})
|
||||
out.push({
|
||||
rel: 'last',
|
||||
title: 'Last',
|
||||
url: `${first}&page=${Math.ceil(total / ctx.state.pagination.perPage)}`,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
57
api/router.mjs
Normal file
57
api/router.mjs
Normal file
|
@ -0,0 +1,57 @@
|
|||
/* eslint max-len: 0 */
|
||||
import Router from 'koa-router'
|
||||
|
||||
import access from './access/index.mjs'
|
||||
import { restrict } from './access/middleware.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 StaffRoutes from './staff/routes.mjs'
|
||||
|
||||
const router = new Router()
|
||||
|
||||
// API Authentication
|
||||
const authentication = new AuthRoutes()
|
||||
router.post('/api/login/user', authentication.loginUser.bind(authentication))
|
||||
|
||||
// API Media
|
||||
// const media = new MediaRoutes()
|
||||
// router.get('/api/media', restrict(access.Manager), media.getAllMedia.bind(media))
|
||||
// router.post('/api/media', restrict(access.Manager), media.upload.bind(media))
|
||||
// router.del('/api/media/:id', restrict(access.Manager), media.removeMedia.bind(media))
|
||||
|
||||
// API File
|
||||
// const file = new FileRoutes()
|
||||
// router.get('/api/file', restrict(access.Manager), file.getAllFiles.bind(file))
|
||||
// router.post('/api/articles/:articleId/file', restrict(access.Manager), file.upload.bind(file))
|
||||
// router.del('/api/file/:id', restrict(access.Manager), file.removeFile.bind(file))
|
||||
|
||||
const page = new PageRoutes()
|
||||
router.get('/api/pagetree', page.getPageTree.bind(page))
|
||||
router.get('/api/pages', page.getAllPages.bind(page))
|
||||
router.get('/api/pages/:id', page.getSinglePage.bind(page))
|
||||
router.post('/api/pages', restrict(access.Manager), page.createPage.bind(page))
|
||||
router.put('/api/pages/:id', restrict(access.Manager), page.updatePage.bind(page))
|
||||
router.del('/api/pages/:id', restrict(access.Manager), page.removePage.bind(page))
|
||||
|
||||
const article = new ArticleRoutes()
|
||||
router.get('/api/articles', restrict(access.Manager), article.getAllArticles.bind(article))
|
||||
router.get('/api/articles/public', article.getPublicAllArticles.bind(article))
|
||||
router.get('/api/articles/public/:id', article.getPublicSingleArticle.bind(article))
|
||||
router.get('/api/pages/:pageId/articles/public', article.getPublicAllPageArticles.bind(article))
|
||||
router.get('/api/articles/:id', restrict(access.Manager), article.getSingleArticle.bind(article))
|
||||
router.post('/api/articles', restrict(access.Manager), article.createArticle.bind(article))
|
||||
router.put('/api/articles/:id', restrict(access.Manager), article.updateArticle.bind(article))
|
||||
router.del('/api/articles/:id', restrict(access.Manager), article.removeArticle.bind(article))
|
||||
|
||||
const staff = new StaffRoutes()
|
||||
router.get('/api/staff', restrict(access.Manager), staff.getAllStaff.bind(staff))
|
||||
router.get('/api/staff/:id', restrict(access.Admin), staff.getSingleStaff.bind(staff))
|
||||
router.post('/api/staff', restrict(access.Admin), staff.createStaff.bind(staff))
|
||||
router.put('/api/staff/:id', restrict(access.Admin), staff.updateStaff.bind(staff))
|
||||
router.del('/api/staff/:id', restrict(access.Admin), staff.removeStaff.bind(staff))
|
||||
|
||||
export default router
|
61
api/serve.mjs
Normal file
61
api/serve.mjs
Normal file
|
@ -0,0 +1,61 @@
|
|||
import send from 'koa-send'
|
||||
import defaults from './defaults.mjs'
|
||||
import access from './access/index.mjs'
|
||||
import { restrict } from './access/middleware.mjs'
|
||||
import { serveIndex } from './serveindex.mjs'
|
||||
import config from './config.mjs'
|
||||
|
||||
const restrictAdmin = restrict(access.Manager)
|
||||
|
||||
export function serve(docRoot, pathname, options = {}) {
|
||||
options.root = docRoot
|
||||
|
||||
return async (ctx, next) => {
|
||||
let opts = defaults({}, options)
|
||||
if (ctx.request.method === 'OPTIONS') return
|
||||
|
||||
let filepath = ctx.path.replace(pathname, '')
|
||||
|
||||
if (filepath === '/') {
|
||||
filepath = '/index.html'
|
||||
}
|
||||
|
||||
if (filepath.endsWith('.jpg')
|
||||
|| filepath.endsWith('.png')
|
||||
|| filepath.endsWith('.js')
|
||||
|| filepath.endsWith('.css')
|
||||
|| filepath.endsWith('.avif')
|
||||
|| filepath.endsWith('.svg')) {
|
||||
if (filepath.indexOf('admin') === -1) {
|
||||
opts = defaults({ maxage: 2592000 * 1000 }, opts)
|
||||
}
|
||||
if (filepath.endsWith('.avif')) {
|
||||
ctx.type = 'image/avif'
|
||||
}
|
||||
}
|
||||
|
||||
if (filepath === '/index.html') {
|
||||
return serveIndex(ctx, '/')
|
||||
}
|
||||
|
||||
if (filepath.indexOf('admin') >= 0
|
||||
&& (filepath.indexOf('js') >= 0
|
||||
|| filepath.indexOf('css') >= 0)) {
|
||||
if (filepath.indexOf('.map') === -1 && filepath.indexOf('.scss') === -1) {
|
||||
await restrictAdmin(ctx)
|
||||
ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||||
} else if (config.get('NODE_ENV') !== 'development') {
|
||||
ctx.status = 404
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return send(ctx, filepath, opts).catch((er) => {
|
||||
if (er.code === 'ENOENT' && er.status === 404) {
|
||||
ctx.type = null
|
||||
return serveIndex(ctx, filepath)
|
||||
// return send(ctx, '/index.html', options)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
222
api/serveindex.mjs
Normal file
222
api/serveindex.mjs
Normal file
|
@ -0,0 +1,222 @@
|
|||
import { readFileSync } from 'fs'
|
||||
import dot from 'dot'
|
||||
import striptags from 'striptags'
|
||||
|
||||
import config from './config.mjs'
|
||||
import Page from './page/model.mjs'
|
||||
// import Article from '../app/article/model.mjs'
|
||||
import Article from './article/model.mjs'
|
||||
|
||||
const body = readFileSync('./public/index.html').toString()
|
||||
const bodyTemplate = dot.template(body)
|
||||
const frontend = config.get('frontend:url')
|
||||
|
||||
function mapArticle(trim = false, x, includeBanner = false, includeFiles = true) {
|
||||
return {
|
||||
id: x.id,
|
||||
published_at: x.published_at,
|
||||
path: x.path,
|
||||
description: x.description,
|
||||
name: x.name,
|
||||
staff: x.staff && ({
|
||||
id: x.staff.id,
|
||||
fullname: x.staff.fullname,
|
||||
}) || null,
|
||||
media: x.media && ({
|
||||
link: !trim && x.media.link || null,
|
||||
large_url: x.media.large_url,
|
||||
large_url_avif: x.media.large_url_avif,
|
||||
medium_url: x.media.medium_url,
|
||||
medium_url_avif: x.media.medium_url_avif,
|
||||
small_url: x.media.small_url,
|
||||
small_url_avif: x.media.small_url_avif,
|
||||
}) || null,
|
||||
banner: x.banner && includeBanner && ({
|
||||
large_url: x.banner.large_url,
|
||||
large_url_avif: x.banner.large_url_avif,
|
||||
medium_url: x.banner.medium_url,
|
||||
medium_url_avif: x.banner.medium_url_avif,
|
||||
small_url: x.banner.small_url,
|
||||
small_url_avif: x.banner.small_url_avif,
|
||||
}) || null,
|
||||
parent: x.parent && ({
|
||||
id: x.parent.id,
|
||||
name: x.parent.name,
|
||||
path: x.parent.path,
|
||||
}),
|
||||
files: x.files && includeFiles && x.files.map(f => ({
|
||||
filename: f.filename,
|
||||
url: f.url,
|
||||
magnet: f.magnet,
|
||||
meta: f.meta.torrent && ({
|
||||
torrent: {
|
||||
name: f.meta.torrent.name,
|
||||
files: f.meta.torrent.files.map(tf => {
|
||||
if (trim && f.meta.torrent.files.length > 4) return 1
|
||||
return {
|
||||
name: tf.name,
|
||||
size: tf.size,
|
||||
}
|
||||
}),
|
||||
},
|
||||
}) || {},
|
||||
})) || [],
|
||||
}
|
||||
}
|
||||
|
||||
function mapPage(x) {
|
||||
return {
|
||||
id: x.id,
|
||||
created_at: x.created_at,
|
||||
path: x.path,
|
||||
description: x.description,
|
||||
name: x.name,
|
||||
media: x.media && ({
|
||||
link: x.media.link,
|
||||
large_url: x.media.large_url,
|
||||
large_url_avif: x.media.large_url_avif,
|
||||
medium_url: x.media.medium_url,
|
||||
medium_url_avif: x.media.medium_url_avif,
|
||||
small_url: x.media.small_url,
|
||||
small_url_avif: x.media.small_url_avif,
|
||||
}) || null,
|
||||
parent: x.parent && ({
|
||||
id: x.parent.id,
|
||||
name: x.parent.name,
|
||||
path: x.parent.path,
|
||||
}),
|
||||
banner: x.banner && ({
|
||||
large_url: x.banner.large_url,
|
||||
large_url_avif: x.banner.large_url_avif,
|
||||
medium_url: x.banner.medium_url,
|
||||
medium_url_avif: x.banner.medium_url_avif,
|
||||
small_url: x.banner.small_url,
|
||||
small_url_avif: x.banner.small_url_avif,
|
||||
}) || null,
|
||||
children: x.children && x.children.map(f => ({
|
||||
id: f.id,
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
})) || [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function serveIndex(ctx, path) {
|
||||
let tree = null
|
||||
let data = null
|
||||
let subdata = null
|
||||
let links = null
|
||||
let featured = null
|
||||
let url = frontend + ctx.request.url
|
||||
let image = frontend + '/assets/img/heart.png'
|
||||
let image_avif = frontend + '/assets/img/heart.png'
|
||||
let title = 'NFP Moe - Anime/Manga translation group'
|
||||
let description = 'Small fansubbing and scanlation group translating and encoding our favourite shows from Japan.'
|
||||
try {
|
||||
tree = await Page.getTree()
|
||||
let currPage = Number(ctx.query.page || '1')
|
||||
|
||||
if (path === '/') {
|
||||
let frontpage = await Article.getFrontpageArticles(currPage)
|
||||
featured = frontpage.featured
|
||||
data = frontpage.items.map(mapArticle.bind(null, true))
|
||||
|
||||
if (frontpage.total > currPage * 10) {
|
||||
links = {
|
||||
first: currPage > 1 ? { page: 1, title: 'First' } : null,
|
||||
previous: currPage > 1 ? { page: currPage - 1, title: 'Previous' } : null,
|
||||
current: { title: 'Page ' + currPage },
|
||||
next: { page: currPage + 1, title: 'Next' },
|
||||
last: { page: Math.ceil(frontpage.total / 10), title: 'Last' },
|
||||
}
|
||||
} else {
|
||||
links = {
|
||||
first: currPage > 1 ? { page: 1, title: 'First' } : null,
|
||||
previous: currPage > 1 ? { page: currPage - 1, title: 'Previous' } : null,
|
||||
current: { title: 'Page 1' },
|
||||
}
|
||||
}
|
||||
if (currPage > 1) {
|
||||
links.previous = { page: currPage - 1, title: 'Previous' }
|
||||
links.first = { page: 1, title: 'First' }
|
||||
}
|
||||
} else if (path.startsWith('/article/') || path.startsWith('/page/')) {
|
||||
let id = path.split('/')[2]
|
||||
if (id) {
|
||||
if (path.startsWith('/article/')) {
|
||||
data = await Article.getSingle(id, ['media', 'parent', 'banner', 'files', 'staff'], false, null, true)
|
||||
if (data) {
|
||||
data = mapArticle(false, data)
|
||||
}
|
||||
} else {
|
||||
data = await Page.getSingle(id, ['media', 'banner', 'children', 'parent'])
|
||||
data = mapPage(data)
|
||||
ctx.state.pagination = {
|
||||
perPage: 10,
|
||||
page: currPage,
|
||||
}
|
||||
subdata = await Article.getAllFromPage(ctx, data.id, ['files', 'media'], '-published_at', true)
|
||||
subdata = subdata.map(mapArticle.bind(null, true))
|
||||
if (ctx.state.pagination.total > currPage * 10) {
|
||||
links = {
|
||||
first: currPage > 1 ? { page: 1, title: 'First' } : null,
|
||||
previous: currPage > 1 ? { page: currPage - 1, title: 'Previous' } : null,
|
||||
current: { title: 'Page ' + currPage },
|
||||
next: { page: currPage + 1, title: 'Next' },
|
||||
last: { page: Math.ceil(ctx.state.pagination.total / 10), title: 'Last' },
|
||||
}
|
||||
} else {
|
||||
links = {
|
||||
first: currPage > 1 ? { page: 1, title: 'First' } : null,
|
||||
previous: currPage > 1 ? { page: currPage - 1, title: 'Previous' } : null,
|
||||
current: { title: 'Page 1' },
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data) {
|
||||
if (data.media) {
|
||||
image = data.media.large_url
|
||||
image_avif = data.media.large_url_avifl
|
||||
} else if (data.banner) {
|
||||
image = data.banner.large_url
|
||||
image_avif = data.banner.large_url_avifl
|
||||
}
|
||||
if (data.description) {
|
||||
description = striptags(data.description)
|
||||
}
|
||||
if (data.parent) {
|
||||
title = data.name + ' - ' + data.parent.name + ' - NFP Moe'
|
||||
} else {
|
||||
title = data.name + ' - NFP Moe'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!featured) {
|
||||
featured = await Article.getFeaturedArticle(['media', 'banner'])
|
||||
}
|
||||
if (featured) {
|
||||
featured = mapArticle(true, featured, true, false)
|
||||
}
|
||||
} catch (e) {
|
||||
ctx.log.error(e)
|
||||
data = null
|
||||
links = null
|
||||
}
|
||||
ctx.body = bodyTemplate({
|
||||
v: config.get('CIRCLECI_VERSION'),
|
||||
tree: JSON.stringify(tree),
|
||||
data: JSON.stringify(data),
|
||||
subdata: JSON.stringify(subdata),
|
||||
links: JSON.stringify(links),
|
||||
featured: JSON.stringify(featured),
|
||||
url: url,
|
||||
image: image,
|
||||
image_avif: image_avif,
|
||||
title: title,
|
||||
description: description,
|
||||
})
|
||||
ctx.set('Content-Length', Buffer.byteLength(ctx.body))
|
||||
ctx.set('Cache-Control', 'max-age=0')
|
||||
ctx.set('Content-Type', 'text/html; charset=utf-8')
|
||||
}
|
39
api/server.mjs
Normal file
39
api/server.mjs
Normal file
|
@ -0,0 +1,39 @@
|
|||
import Koa from 'koa-lite'
|
||||
import bodyParser from 'koa-bodyparser'
|
||||
import cors from '@koa/cors'
|
||||
|
||||
import config from './config.mjs'
|
||||
import router from './router.mjs'
|
||||
import Jwt from './jwt.mjs'
|
||||
import log from './log.mjs'
|
||||
import { serve } from './serve.mjs'
|
||||
import { mask } from './middlewares/mask.mjs'
|
||||
import { errorHandler } from './error/middleware.mjs'
|
||||
import { accessChecks } from './access/middleware.mjs'
|
||||
import ParserMiddleware from './parser/middleware.mjs'
|
||||
|
||||
const app = new Koa()
|
||||
const parser = new ParserMiddleware()
|
||||
|
||||
app.use(log.logMiddleware())
|
||||
app.use(errorHandler())
|
||||
app.use(mask())
|
||||
app.use(bodyParser())
|
||||
app.use(parser.contextParser())
|
||||
app.use(accessChecks())
|
||||
app.use(parser.generateLinks())
|
||||
app.use(Jwt.jwtMiddleware())
|
||||
app.use(cors({
|
||||
exposeHeaders: ['link', 'pagination_total'],
|
||||
credentials: true,
|
||||
}))
|
||||
app.use(router.routes())
|
||||
app.use(router.allowedMethods())
|
||||
app.use(serve('./public', '/public'))
|
||||
|
||||
const server = app.listen(
|
||||
config.get('server:port'),
|
||||
() => log.info(`Server running on port ${config.get('server:port')}`)
|
||||
)
|
||||
|
||||
export default server
|
28
api/setup.mjs
Normal file
28
api/setup.mjs
Normal file
|
@ -0,0 +1,28 @@
|
|||
import _ from 'lodash'
|
||||
|
||||
import config from './config.mjs'
|
||||
import log from './log.mjs'
|
||||
import knex from 'knex-core'
|
||||
|
||||
// This is important for setup to run cleanly.
|
||||
let knexConfig = _.cloneDeep(config.get('knex'))
|
||||
knexConfig.pool = { min: 1, max: 1 }
|
||||
|
||||
let knexSetup = knex(knexConfig)
|
||||
|
||||
export default function setup() {
|
||||
log.info(knexConfig, 'Running database integrity scan.')
|
||||
|
||||
return knexSetup.migrate.latest({
|
||||
directory: './migrations',
|
||||
})
|
||||
.then((result) => {
|
||||
if (result[1].length === 0) {
|
||||
return log.info('Database is up to date')
|
||||
}
|
||||
for (let i = 0; i < result[1].length; i++) {
|
||||
log.info('Applied migration from', result[1][i].substr(result[1][i].lastIndexOf('\\') + 1))
|
||||
}
|
||||
return knexSetup.destroy()
|
||||
})
|
||||
}
|
82
api/staff/model.mjs
Normal file
82
api/staff/model.mjs
Normal file
|
@ -0,0 +1,82 @@
|
|||
import { createPrototype, safeColumns } from '../knex.mjs'
|
||||
import bcrypt from 'bcrypt'
|
||||
/*import config from '../config.mjs'*/
|
||||
|
||||
/* Staff model:
|
||||
{
|
||||
id,
|
||||
username,
|
||||
password,
|
||||
fullname,
|
||||
is_deleted,
|
||||
level,
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
function StaffItem(data) {
|
||||
Object.assign(this, data)
|
||||
}
|
||||
|
||||
function Staff() {
|
||||
this.tableName = 'staff'
|
||||
this.Model = StaffItem
|
||||
this.privateFields = safeColumns(['fullname','email','level',])
|
||||
this.publicFields = ['id', 'fullname']
|
||||
this.init()
|
||||
}
|
||||
|
||||
Staff.prototype = createPrototype({
|
||||
hash(password) {
|
||||
return new Promise((resolve, reject) =>
|
||||
bcrypt.hash(password, config.get('bcrypt'), (err, hashed) => {
|
||||
if (err) return reject(err)
|
||||
|
||||
resolve(hashed)
|
||||
})
|
||||
)
|
||||
},
|
||||
|
||||
compare(password, hashed) {
|
||||
return new Promise((resolve, reject) =>
|
||||
bcrypt.compare(password, hashed, (err, res) => {
|
||||
if (err || !res) return reject(new Error('PasswordMismatch'))
|
||||
resolve()
|
||||
})
|
||||
)
|
||||
},
|
||||
|
||||
_getSingle(subq, includes = [], require = true, ctx = null) {
|
||||
return this.getSingleQuery(this.query(qb => {
|
||||
return qb
|
||||
.where(qb => {
|
||||
if (subq) subq(qb)
|
||||
})
|
||||
}, includes, this.privateFields), require)
|
||||
},
|
||||
|
||||
/* getAll(ctx, where = {}, withRelated = [], orderBy = 'id') {
|
||||
return this.query(qb => {
|
||||
this.baseQueryAll(ctx, qb, where, orderBy)
|
||||
qb.select(bookshelf.safeColumns([
|
||||
'fullname',
|
||||
'email',
|
||||
'level',
|
||||
]))
|
||||
})
|
||||
.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 new Staff()
|
44
api/staff/routes.mjs
Normal file
44
api/staff/routes.mjs
Normal file
|
@ -0,0 +1,44 @@
|
|||
import Staff from './model.mjs'
|
||||
import * as security from './security.mjs'
|
||||
|
||||
export default class StaffRoutes {
|
||||
constructor(opts = {}) {
|
||||
Object.assign(this, {
|
||||
Staff: opts.Staff || Staff,
|
||||
security: opts.security || security,
|
||||
})
|
||||
}
|
||||
|
||||
/** GET: /api/staff */
|
||||
async getAllStaff(ctx) {
|
||||
ctx.body = await this.Staff.getAll(ctx, null, [])
|
||||
}
|
||||
|
||||
/** GET: /api/staff/:id */
|
||||
async getSingleStaff(ctx) {
|
||||
ctx.body = await this.Staff.getSingle(ctx.params.id, [], true, ctx)
|
||||
}
|
||||
|
||||
/** POST: /api/staff */
|
||||
async createStaff(ctx) {
|
||||
await this.security.validUpdate(ctx)
|
||||
|
||||
ctx.body = await this.Staff.create(ctx.request.body)
|
||||
}
|
||||
|
||||
/** PUT: /api/staff/:id */
|
||||
async updateStaff(ctx) {
|
||||
await this.security.validUpdate(ctx)
|
||||
|
||||
let staff = await this.Staff.updateSingle(ctx, ctx.params.id, ctx.request.body)
|
||||
|
||||
ctx.body = staff
|
||||
}
|
||||
|
||||
/** DELETE: /api/staff/:id */
|
||||
async removeStaff(ctx) {
|
||||
await this.Staff.updateSingle(ctx, ctx.params.id, { is_deleted: true })
|
||||
|
||||
ctx.status = 204
|
||||
}
|
||||
}
|
21
api/staff/security.mjs
Normal file
21
api/staff/security.mjs
Normal file
|
@ -0,0 +1,21 @@
|
|||
import filter from '../filter.mjs'
|
||||
import Staff from './model.mjs'
|
||||
|
||||
const validFields = [
|
||||
'fullname',
|
||||
'email',
|
||||
'password',
|
||||
'level',
|
||||
]
|
||||
|
||||
export async function validUpdate(ctx) {
|
||||
let out = filter(Object.keys(ctx.request.body), validFields)
|
||||
|
||||
if (out.length > 0) {
|
||||
ctx.throw(422, `Body had following invalid properties: ${out.join(', ')}`)
|
||||
}
|
||||
|
||||
if (ctx.request.body.password) {
|
||||
ctx.request.body.password = await Staff.hash(ctx.request.body.password)
|
||||
}
|
||||
}
|
67
app/_common.scss
Normal file
67
app/_common.scss
Normal file
|
@ -0,0 +1,67 @@
|
|||
|
||||
$primary-bg: #01579b;
|
||||
$primary-fg: white;
|
||||
$primary-fg-url: #FFC7C7;
|
||||
$primary-light-bg: #3D77C7; // #4f83cc;
|
||||
$primary-light-fg: white;
|
||||
$primary-dark-bg: #002f6c;
|
||||
$primary-dark-fg: white;
|
||||
|
||||
$secondary-bg: #f57c00;
|
||||
$secondary-fg: black;
|
||||
$secondary-light-bg: #ffad42;
|
||||
$secondary-light-fg: black;
|
||||
$secondary-dark-bg: #bb4d00;
|
||||
$secondary-dark-fg: white;
|
||||
|
||||
$table-fg: #333;
|
||||
$border: #ccc;
|
||||
$border-fg: black;
|
||||
$border-fg-url: #8f3c00;
|
||||
$title-fg: #555;
|
||||
$meta-fg: #757575; // #999
|
||||
$meta-light-fg: #999999;
|
||||
|
||||
$main-bg: white;
|
||||
$main-fg: black;
|
||||
$input-bg: white;
|
||||
$input-border: #333;
|
||||
$input-fg: black;
|
||||
|
||||
$newsitem-bg: transparent;
|
||||
$newsitem-border: none;
|
||||
|
||||
/* Dark theme */
|
||||
|
||||
$dark_primary-bg: #013b68;
|
||||
$dark_primary-fg: white;
|
||||
$dark_primary-fg-url: #FFC7C7;
|
||||
$dark_primary-light-bg: #28518B;
|
||||
$dark_primary-light-fg: white;
|
||||
$dark_primary-dark-bg: #002f6c;
|
||||
$dark_primary-dark-fg: white;
|
||||
|
||||
$dark_secondary-bg: #e05e00;
|
||||
$dark_secondary-fg: black;
|
||||
$dark_secondary-light-bg: #ffad42;
|
||||
$dark_secondary-light-fg: black;
|
||||
$dark_secondary-dark-bg: #e05e00;
|
||||
$dark_secondary-dark-fg: white;
|
||||
$dark_secondary-darker-fg: #fe791b;
|
||||
|
||||
$dark_table-fg: #333;
|
||||
$dark_border: #343536;
|
||||
$dark_border-fg: #d7dadc;;
|
||||
$dark_border-fg-url: #e05e00;
|
||||
$dark_title-fg: #808080;
|
||||
$dark_meta-fg: hsl(0, 0%, 55%);
|
||||
$dark_meta-light-fg: #999999;
|
||||
|
||||
$dark_main-bg: black;
|
||||
$dark_main-fg: #d7dadc;
|
||||
$dark_input-bg: #272729;
|
||||
$dark_input-border: #343536;
|
||||
$dark_input-fg: white;
|
||||
|
||||
$dark_newsitem-bg: #1a1a1b;
|
||||
$dark_newsitem-border: 1px solid #343536;
|
12
app/admin.js
Normal file
12
app/admin.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
const EditPage = require('./admin/editpage')
|
||||
const AdminPages = require('./admin/pages')
|
||||
const AdminArticles = require('./admin/articles')
|
||||
const EditArticle = require('./admin/editarticle')
|
||||
const AdminStaffList = require('./admin/stafflist')
|
||||
const EditStaff = require('./admin/editstaff')
|
||||
|
||||
window.adminRoutes = {
|
||||
pages: [AdminPages, EditPage],
|
||||
articles: [AdminArticles, EditArticle],
|
||||
staff: [AdminStaffList, EditStaff],
|
||||
}
|
79
app/admin.scss
Normal file
79
app/admin.scss
Normal file
|
@ -0,0 +1,79 @@
|
|||
@import './_common';
|
||||
|
||||
.error {
|
||||
font-size: 0.8em;
|
||||
color: $secondary-dark-bg;
|
||||
font-weight: bold;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
$bordercolor: $primary-bg;
|
||||
$headcolor: $primary-light-bg;
|
||||
$headtext: $primary-light-fg;
|
||||
|
||||
.admin-wrapper table {
|
||||
width: calc(100% - 20px);
|
||||
margin: 10px;
|
||||
border: solid 1px $bordercolor;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
font-size: 0.8em;
|
||||
|
||||
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: $table-fg;
|
||||
padding: 10px;
|
||||
}
|
||||
a,
|
||||
a:visited,
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
color: $secondary-dark-bg;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button {
|
||||
color: $secondary-dark-bg;
|
||||
background: transparent;
|
||||
border: 1px solid $secondary-dark-bg;
|
||||
}
|
||||
|
||||
td.right,
|
||||
th.right {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.floating-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #00000099;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@import 'admin/admin';
|
||||
@import 'widgets/admin';
|
||||
|
||||
.darkmodeon {
|
||||
.maincontainer .admin-wrapper {
|
||||
color: $main-fg;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $dark_secondary-dark-bg;
|
||||
}
|
||||
}
|
40
app/admin/admin.scss
Normal file
40
app/admin/admin.scss
Normal file
|
@ -0,0 +1,40 @@
|
|||
|
||||
.admin-wrapper {
|
||||
flex-grow: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: $primary-bg;
|
||||
padding: 0 20px 50px;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
background: $primary-bg;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 37px;
|
||||
|
||||
span {
|
||||
color: $primary-fg;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
a {
|
||||
padding: 10px;
|
||||
text-decoration: none;
|
||||
color: $secondary-light-bg;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.fr-box,
|
||||
.fr-toolbar,
|
||||
.fr-box .second-toolbar {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
@import 'pages';
|
||||
@import 'articles';
|
||||
@import 'staff';
|
141
app/admin/articles.js
Normal file
141
app/admin/articles.js
Normal file
|
@ -0,0 +1,141 @@
|
|||
const Article = require('../api/article')
|
||||
const pagination = require('../api/pagination')
|
||||
const Dialogue = require('../widgets/dialogue')
|
||||
const Pages = require('../widgets/pages')
|
||||
|
||||
const AdminArticles = {
|
||||
oninit: function(vnode) {
|
||||
this.error = ''
|
||||
this.lastpage = m.route.param('page') || '1'
|
||||
this.articles = []
|
||||
this.removeArticle = null
|
||||
|
||||
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'
|
||||
|
||||
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
|
||||
})
|
||||
.catch(function(err) {
|
||||
vnode.state.error = err.message
|
||||
})
|
||||
.then(function() {
|
||||
vnode.state.loading = false
|
||||
m.redraw()
|
||||
})
|
||||
},
|
||||
|
||||
confirmRemoveArticle: function(vnode) {
|
||||
let removingArticle = this.removeArticle
|
||||
this.removeArticle = null
|
||||
this.loading = true
|
||||
Article.removeArticle(removingArticle, removingArticle.id)
|
||||
.then(this.oninit.bind(this, vnode))
|
||||
.catch(function(err) {
|
||||
vnode.state.error = err.message
|
||||
vnode.state.loading = false
|
||||
m.redraw()
|
||||
})
|
||||
},
|
||||
|
||||
drawArticle: function(vnode, article) {
|
||||
let parent
|
||||
if (article.parent) {
|
||||
parent = {
|
||||
path: '/page/' + article.parent.path,
|
||||
name: article.parent.name,
|
||||
}
|
||||
} else {
|
||||
parent = {
|
||||
path: '/',
|
||||
name: '-- Frontpage --',
|
||||
}
|
||||
}
|
||||
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('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', m('button', { onclick: function() { vnode.state.removeArticle = article } }, 'Remove')),
|
||||
]),
|
||||
]
|
||||
},
|
||||
|
||||
view: function(vnode) {
|
||||
return [
|
||||
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'),
|
||||
m('th', 'Page'),
|
||||
m('th', 'Path'),
|
||||
m('th.right', 'Publish'),
|
||||
m('th.right', 'By'),
|
||||
m('th.right', 'Actions'),
|
||||
])
|
||||
),
|
||||
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 : ''),
|
||||
message: 'Are you sure you want to remove "' + (vnode.state.removeArticle ? vnode.state.removeArticle.name : '') + '" (' + (vnode.state.removeArticle ? vnode.state.removeArticle.path : '') + ')',
|
||||
yes: 'Remove',
|
||||
yesclass: 'alert',
|
||||
no: 'Cancel',
|
||||
noclass: 'cancel',
|
||||
onyes: this.confirmRemoveArticle.bind(this, vnode),
|
||||
onno: function() { vnode.state.removeArticle = null },
|
||||
}),
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = AdminArticles
|
130
app/admin/articles.scss
Normal file
130
app/admin/articles.scss
Normal file
|
@ -0,0 +1,130 @@
|
|||
|
||||
article.editarticle {
|
||||
text-align: center;
|
||||
background: white;
|
||||
padding: 0 0 20px;
|
||||
|
||||
header {
|
||||
padding: 10px;
|
||||
background: $secondary-bg;
|
||||
|
||||
h1 {
|
||||
color: $secondary-fg;
|
||||
}
|
||||
|
||||
a {
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
margin-left: 10px;
|
||||
color: $secondary-light-fg;
|
||||
}
|
||||
}
|
||||
|
||||
fileupload {
|
||||
margin: 0 0 20px;
|
||||
|
||||
.inside {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
fileupload.cover {
|
||||
align-self: center;
|
||||
min-width: 178px;
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 0 40px 20px;
|
||||
|
||||
textarea {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
label.slim {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
input.slim {
|
||||
font-size: 0.8em;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button.submit {
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
tr.rowhidden td {
|
||||
background: #e6e6e6;
|
||||
}
|
||||
|
||||
tr.rowfeatured td {
|
||||
background: hsl(120, 60%, 85%);
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
& > .loading-spinner {
|
||||
width: 240px;
|
||||
height: 50px;
|
||||
position: relative;
|
||||
|
||||
&.full {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.fileupload {
|
||||
align-self: center;
|
||||
padding: 0.5em;
|
||||
margin: 0.5em 0;
|
||||
min-width: 250px;
|
||||
border: none;
|
||||
border: 1px solid $secondary-bg;
|
||||
background: $secondary-light-bg;
|
||||
color: $secondary-light-fg;
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
opacity: 0.01;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
text-indent: -9999px;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
padding: 0 5px;
|
||||
}
|
374
app/admin/editarticle.js
Normal file
374
app/admin/editarticle.js
Normal file
|
@ -0,0 +1,374 @@
|
|||
const Authentication = require('../authentication')
|
||||
const FileUpload = require('../widgets/fileupload')
|
||||
const Staff = require('../api/staff')
|
||||
const Froala = require('./froala')
|
||||
const Page = require('../api/page')
|
||||
const File = require('../api/file')
|
||||
const Fileinfo = require('../widgets/fileinfo')
|
||||
const Article = require('../api/article')
|
||||
|
||||
const EditArticle = {
|
||||
getFroalaOptions: function() {
|
||||
return {
|
||||
theme: 'gray',
|
||||
heightMin: 150,
|
||||
videoUpload: false,
|
||||
imageUploadURL: '/api/media',
|
||||
imageManagerLoadURL: '/api/media',
|
||||
imageManagerDeleteMethod: 'DELETE',
|
||||
imageManagerDeleteURL: '/api/media',
|
||||
events: {
|
||||
'imageManager.beforeDeleteImage': function(img) {
|
||||
this.opts.imageManagerDeleteURL = '/api/media/' + img.data('id')
|
||||
},
|
||||
},
|
||||
requestHeaders: {
|
||||
'Authorization': 'Bearer ' + Authentication.getToken(),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
oninit: function(vnode) {
|
||||
this.froala = null
|
||||
this.loadedFroala = Froala.loadedFroala
|
||||
this.staffers = []
|
||||
|
||||
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.fetchArticle(vnode)
|
||||
},
|
||||
|
||||
onupdate: 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)
|
||||
}
|
||||
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
|
||||
} else {
|
||||
this.article[name] = e.currentTarget.value
|
||||
}
|
||||
if (name === 'path') {
|
||||
this.editedPath = true
|
||||
} else if (name === 'name' && !this.editedPath) {
|
||||
this.article.path = this.article.name.toLowerCase().replace(/ /g, '-')
|
||||
}
|
||||
},
|
||||
|
||||
updateParent: function(e) {
|
||||
this.article.parent_id = Number(e.currentTarget.value)
|
||||
if (this.article.parent_id === -1) {
|
||||
this.article.parent_id = null
|
||||
}
|
||||
},
|
||||
|
||||
updateStaffer: function(e) {
|
||||
this.article.staff_id = Number(e.currentTarget.value)
|
||||
},
|
||||
|
||||
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
|
||||
|
||||
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.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,
|
||||
})
|
||||
} 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,
|
||||
})
|
||||
}
|
||||
|
||||
promise.then(function(res) {
|
||||
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
|
||||
EditArticle.parsePublishedAt(vnode, null)
|
||||
} else {
|
||||
m.route.set('/admin/articles/' + res.id)
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
vnode.state.error = err.message
|
||||
})
|
||||
.then(function() {
|
||||
vnode.state.loading = false
|
||||
m.redraw()
|
||||
})
|
||||
},
|
||||
|
||||
uploadFile: function(vnode, event) {
|
||||
if (!event.target.files[0]) return
|
||||
vnode.state.error = ''
|
||||
vnode.state.loadingFile = true
|
||||
|
||||
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 (
|
||||
this.loading ?
|
||||
m('div.loading-spinner')
|
||||
: m('div.admin-wrapper', [
|
||||
m('div.admin-actions', this.article.id
|
||||
? [
|
||||
m('span', 'Actions:'),
|
||||
m(m.route.Link, { href: '/article/' + this.article.path }, 'View article'),
|
||||
]
|
||||
: null),
|
||||
m('article.editarticle', [
|
||||
m('header', m('h1', this.creating ? 'Create Article' : 'Edit ' + (this.article.name || '(untitled)'))),
|
||||
m('div.error', {
|
||||
hidden: !this.error,
|
||||
onclick: function() { vnode.state.error = '' },
|
||||
}, this.error),
|
||||
m(FileUpload, {
|
||||
height: 300,
|
||||
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.mediaUploaded.bind(this, 'media'),
|
||||
ondelete: this.mediaRemoved.bind(this, 'media'),
|
||||
onerror: function(e) { vnode.state.error = e },
|
||||
media: this.article && this.article.media,
|
||||
}),
|
||||
m('form.editarticle.content', {
|
||||
onsubmit: this.save.bind(this, vnode),
|
||||
}, [
|
||||
m('label', 'Parent'),
|
||||
m('select', {
|
||||
onchange: this.updateParent.bind(this),
|
||||
}, parents.map(function(item) { return m('option', { value: item.id || -1, selected: item.id === vnode.state.article.parent_id }, item.name) })),
|
||||
m('label', 'Name'),
|
||||
m('input', {
|
||||
type: 'text',
|
||||
value: this.article.name,
|
||||
oninput: this.updateValue.bind(this, 'name'),
|
||||
}),
|
||||
m('label.slim', 'Path'),
|
||||
m('input.slim', {
|
||||
type: 'text',
|
||||
value: this.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)
|
||||
})
|
||||
},
|
||||
})
|
||||
: null
|
||||
),
|
||||
m('label', 'Published at'),
|
||||
m('input', {
|
||||
type: 'datetime-local',
|
||||
value: this.article.published_at,
|
||||
oninput: this.updateValue.bind(this, 'published_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) })),
|
||||
m('label', 'Make featured'),
|
||||
m('input', {
|
||||
type: 'checkbox',
|
||||
checked: this.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')
|
||||
: 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,
|
||||
]),
|
||||
])
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = EditArticle
|
246
app/admin/editpage.js
Normal file
246
app/admin/editpage.js
Normal file
|
@ -0,0 +1,246 @@
|
|||
const Authentication = require('../authentication')
|
||||
const FileUpload = require('../widgets/fileupload')
|
||||
const Froala = require('./froala')
|
||||
const Page = require('../api/page')
|
||||
|
||||
const EditPage = {
|
||||
getFroalaOptions: function() {
|
||||
return {
|
||||
theme: 'gray',
|
||||
heightMin: 150,
|
||||
videoUpload: false,
|
||||
imageUploadURL: '/api/media',
|
||||
imageManagerLoadURL: '/api/media',
|
||||
imageManagerDeleteMethod: 'DELETE',
|
||||
imageManagerDeleteURL: '/api/media',
|
||||
events: {
|
||||
'imageManager.beforeDeleteImage': function(img) {
|
||||
this.opts.imageManagerDeleteURL = '/api/media/' + img.data('id')
|
||||
},
|
||||
},
|
||||
requestHeaders: {
|
||||
'Authorization': 'Bearer ' + Authentication.getToken(),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
oninit: function(vnode) {
|
||||
this.froala = null
|
||||
this.loadedFroala = Froala.loadedFroala
|
||||
|
||||
if (!this.loadedFroala) {
|
||||
Froala.createFroalaScript()
|
||||
.then(function() {
|
||||
vnode.state.loadedFroala = true
|
||||
m.redraw()
|
||||
})
|
||||
}
|
||||
|
||||
this.fetchPage(vnode)
|
||||
},
|
||||
|
||||
onupdate: function(vnode) {
|
||||
if (this.lastid !== m.route.param('id')) {
|
||||
this.fetchPage(vnode)
|
||||
if (this.lastid === 'add') {
|
||||
m.redraw()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
fetchPage: function(vnode) {
|
||||
this.lastid = m.route.param('id')
|
||||
this.loading = this.lastid !== 'add'
|
||||
this.creating = this.lastid === 'add'
|
||||
this.error = ''
|
||||
this.page = {
|
||||
name: '',
|
||||
path: '',
|
||||
description: '',
|
||||
media: null,
|
||||
}
|
||||
this.editedPath = false
|
||||
|
||||
if (this.lastid !== 'add') {
|
||||
Page.getPage(this.lastid)
|
||||
.then(function(result) {
|
||||
vnode.state.editedPath = true
|
||||
vnode.state.page = result
|
||||
document.title = 'Editing: ' + result.name + ' - Admin NFP Moe'
|
||||
})
|
||||
.catch(function(err) {
|
||||
vnode.state.error = err.message
|
||||
})
|
||||
.then(function() {
|
||||
vnode.state.loading = false
|
||||
m.redraw()
|
||||
})
|
||||
} else {
|
||||
document.title = 'Create Page - Admin NFP Moe'
|
||||
}
|
||||
},
|
||||
|
||||
updateValue: function(name, e) {
|
||||
this.page[name] = e.currentTarget.value
|
||||
if (name === 'path') {
|
||||
this.editedPath = true
|
||||
} else if (name === 'name' && !this.editedPath) {
|
||||
this.page.path = this.page.name.toLowerCase().replace(/ /g, '-')
|
||||
}
|
||||
},
|
||||
|
||||
updateParent: function(e) {
|
||||
this.page.parent_id = Number(e.currentTarget.value)
|
||||
if (this.page.parent_id === -1) {
|
||||
this.page.parent_id = null
|
||||
}
|
||||
},
|
||||
|
||||
fileUploaded: function(type, media) {
|
||||
this.page[type] = media
|
||||
},
|
||||
|
||||
fileRemoved: function(type) {
|
||||
this.page[type] = null
|
||||
},
|
||||
|
||||
save: function(vnode, e) {
|
||||
e.preventDefault()
|
||||
if (!this.page.name) {
|
||||
this.error = 'Name is missing'
|
||||
} else if (!this.page.path) {
|
||||
this.error = 'Path is missing'
|
||||
} else {
|
||||
this.error = ''
|
||||
}
|
||||
if (this.error) return
|
||||
|
||||
this.page.description = vnode.state.froala ? vnode.state.froala.html.get() : this.page.description
|
||||
if (this.page.description) {
|
||||
this.page.description = this.page.description.replace(/<p[^>]+data-f-id="pbf"[^>]+>[^>]+>[^>]+>[^>]+>/, '')
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
|
||||
let promise
|
||||
|
||||
if (this.page.id) {
|
||||
promise = Page.updatePage(this.page.id, {
|
||||
name: this.page.name,
|
||||
path: this.page.path,
|
||||
parent_id: this.page.parent_id,
|
||||
description: this.page.description,
|
||||
banner_id: this.page.banner && this.page.banner.id || null,
|
||||
media_id: this.page.media && this.page.media.id || null,
|
||||
})
|
||||
} else {
|
||||
promise = Page.createPage({
|
||||
name: this.page.name,
|
||||
path: this.page.path,
|
||||
parent_id: this.page.parent_id,
|
||||
description: this.page.description,
|
||||
banner_id: this.page.banner && this.page.banner.id || null,
|
||||
media_id: this.page.media && this.page.media.id || null,
|
||||
})
|
||||
}
|
||||
|
||||
promise.then(function(res) {
|
||||
if (vnode.state.page.id) {
|
||||
res.media = vnode.state.page.media
|
||||
res.banner = vnode.state.page.banner
|
||||
vnode.state.page = res
|
||||
console.log(res)
|
||||
} else {
|
||||
m.route.set('/admin/pages/' + res.id)
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
vnode.state.error = err.message
|
||||
})
|
||||
.then(function() {
|
||||
vnode.state.loading = false
|
||||
m.redraw()
|
||||
})
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
view: function(vnode) {
|
||||
const parents = [{id: null, name: '-- Frontpage --'}].concat(Page.Tree).filter(function (page) { return !vnode.state.page || page.id !== vnode.state.page.id})
|
||||
return (
|
||||
this.loading ?
|
||||
m('div.loading-spinner')
|
||||
: m('div.admin-wrapper', [
|
||||
m('div.admin-actions', this.page.id
|
||||
? [
|
||||
m('span', 'Actions:'),
|
||||
m(m.route.Link, { href: '/page/' + this.page.path }, 'View page'),
|
||||
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 = '' },
|
||||
}, 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,
|
||||
}),
|
||||
m(FileUpload, {
|
||||
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,
|
||||
}),
|
||||
m('form.editpage.content', {
|
||||
onsubmit: this.save.bind(this, vnode),
|
||||
}, [
|
||||
m('label', 'Parent'),
|
||||
m('select', {
|
||||
onchange: this.updateParent.bind(this),
|
||||
}, parents.map(function(item) {
|
||||
return m('option', { value: item.id || -1, selected: item.id === vnode.state.page.parent_id }, item.name)
|
||||
})),
|
||||
m('label', 'Name'),
|
||||
m('input', {
|
||||
type: 'text',
|
||||
value: this.page.name,
|
||||
oninput: this.updateValue.bind(this, 'name'),
|
||||
}),
|
||||
m('label', 'Description'),
|
||||
(
|
||||
this.loadedFroala ?
|
||||
m('div', {
|
||||
oncreate: function(div) {
|
||||
vnode.state.froala = new FroalaEditor(div.dom, EditPage.getFroalaOptions(), function() {
|
||||
vnode.state.froala.html.set(vnode.state.page.description)
|
||||
})
|
||||
},
|
||||
})
|
||||
: null
|
||||
),
|
||||
m('label', 'Path'),
|
||||
m('input', {
|
||||
type: 'text',
|
||||
value: this.page.path,
|
||||
oninput: this.updateValue.bind(this, 'path'),
|
||||
}),
|
||||
m('div.loading-spinner', { hidden: this.loadedFroala }),
|
||||
m('input', {
|
||||
type: 'submit',
|
||||
value: 'Save',
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = EditPage
|
|
@ -1,5 +1,6 @@
|
|||
const Staff = require('../api/staff')
|
||||
|
||||
const EditStaff = {
|
||||
/*
|
||||
oninit: function(vnode) {
|
||||
this.fetchStaff(vnode)
|
||||
},
|
||||
|
@ -16,10 +17,10 @@ const EditStaff = {
|
|||
this.creating = this.lastid === 'add'
|
||||
this.error = ''
|
||||
this.staff = {
|
||||
name: '',
|
||||
fullname: '',
|
||||
email: '',
|
||||
password: '',
|
||||
rank: 10,
|
||||
level: 10,
|
||||
}
|
||||
|
||||
if (this.lastid !== 'add') {
|
||||
|
@ -27,7 +28,7 @@ const EditStaff = {
|
|||
.then(function(result) {
|
||||
vnode.state.editedPath = true
|
||||
vnode.state.staff = result
|
||||
document.title = 'Editing: ' + result.name + ' - Admin NFP Moe'
|
||||
document.title = 'Editing: ' + result.fullname + ' - Admin NFP Moe'
|
||||
})
|
||||
.catch(function(err) {
|
||||
vnode.state.error = err.message
|
||||
|
@ -41,13 +42,13 @@ const EditStaff = {
|
|||
}
|
||||
},
|
||||
|
||||
updateValue: function(key, e) {
|
||||
this.staff[key] = e.currentTarget.value
|
||||
updateValue: function(fullname, e) {
|
||||
this.staff[fullname] = e.currentTarget.value
|
||||
},
|
||||
|
||||
save: function(vnode, e) {
|
||||
e.preventDefault()
|
||||
if (!this.staff.name) {
|
||||
if (!this.staff.fullname) {
|
||||
this.error = 'Fullname is missing'
|
||||
} else if (!this.staff.email) {
|
||||
this.error = 'Email is missing'
|
||||
|
@ -64,16 +65,16 @@ const EditStaff = {
|
|||
|
||||
if (this.staff.id) {
|
||||
promise = Staff.updateStaff(this.staff.id, {
|
||||
name: this.staff.name,
|
||||
fullname: this.staff.fullname,
|
||||
email: this.staff.email,
|
||||
rank: this.staff.rank,
|
||||
level: this.staff.level,
|
||||
password: this.staff.password,
|
||||
})
|
||||
} else {
|
||||
promise = Staff.createStaff({
|
||||
name: this.staff.name,
|
||||
fullname: this.staff.fullname,
|
||||
email: this.staff.email,
|
||||
rank: this.staff.rank,
|
||||
level: this.staff.level,
|
||||
password: this.staff.password,
|
||||
})
|
||||
}
|
||||
|
@ -91,11 +92,11 @@ const EditStaff = {
|
|||
},
|
||||
|
||||
updateLevel: function(e) {
|
||||
this.staff.rank = Number(e.currentTarget.value)
|
||||
this.staff.level = Number(e.currentTarget.value)
|
||||
},
|
||||
|
||||
view: function(vnode) {
|
||||
const ranks = [[10, 'Manager'], [100, 'Admin']]
|
||||
const levels = [[10, 'Manager'], [100, 'Admin']]
|
||||
return (
|
||||
this.loading ?
|
||||
m('div.loading-spinner')
|
||||
|
@ -107,7 +108,7 @@ const EditStaff = {
|
|||
]
|
||||
: null),
|
||||
m('article.editstaff', [
|
||||
m('header', m('h1', this.creating ? 'Create Staff' : 'Edit ' + (this.staff.name || '(untitled)'))),
|
||||
m('header', m('h1', this.creating ? 'Create Staff' : 'Edit ' + (this.staff.fullname || '(untitled)'))),
|
||||
m('div.error', {
|
||||
hidden: !this.error,
|
||||
onclick: function() { vnode.state.error = '' },
|
||||
|
@ -118,12 +119,12 @@ const EditStaff = {
|
|||
m('label', 'Level'),
|
||||
m('select', {
|
||||
onchange: this.updateLevel.bind(this),
|
||||
}, ranks.map(function(rank) { return m('option', { value: rank[0], selected: rank[0] === vnode.state.staff.rank }, rank[1]) })),
|
||||
}, levels.map(function(level) { return m('option', { value: level[0], selected: level[0] === vnode.state.staff.level }, level[1]) })),
|
||||
m('label', 'Fullname'),
|
||||
m('input', {
|
||||
type: 'text',
|
||||
value: this.staff.name,
|
||||
oninput: this.updateValue.bind(this, 'name'),
|
||||
value: this.staff.fullname,
|
||||
oninput: this.updateValue.bind(this, 'fullname'),
|
||||
}),
|
||||
m('label', 'Email'),
|
||||
m('input', {
|
||||
|
@ -145,7 +146,7 @@ const EditStaff = {
|
|||
]),
|
||||
])
|
||||
)
|
||||
},*/
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = EditStaff
|
46
app/admin/froala.js
Normal file
46
app/admin/froala.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
const Froala = {
|
||||
files: [
|
||||
{ type: 'css', url: 'https://cdn.jsdelivr.net/npm/froala-editor@3.0.4/css/froala_editor.pkgd.min.css' },
|
||||
{ type: 'css', url: 'https://cdn.jsdelivr.net/npm/froala-editor@3.0.4/css/themes/gray.min.css' },
|
||||
{ type: 'js', url: 'https://cdn.jsdelivr.net/npm/froala-editor@3.0.4/js/froala_editor.pkgd.min.js' },
|
||||
],
|
||||
loadedFiles: 0,
|
||||
loadedFroala: false,
|
||||
|
||||
checkLoadedAll: function(res) {
|
||||
if (Froala.loadedFiles < Froala.files.length) {
|
||||
return
|
||||
}
|
||||
Froala.loadedFroala = true
|
||||
res()
|
||||
},
|
||||
|
||||
createFroalaScript: function() {
|
||||
if (Froala.loadedFroala) return Promise.resolve()
|
||||
return new Promise(function(res) {
|
||||
let onload = function() {
|
||||
Froala.loadedFiles++
|
||||
Froala.checkLoadedAll(res)
|
||||
}
|
||||
let head = document.getElementsByTagName('head')[0]
|
||||
|
||||
for (var i = 0; i < Froala.files.length; i++) {
|
||||
let element
|
||||
if (Froala.files[i].type === 'css') {
|
||||
element = document.createElement('link')
|
||||
element.setAttribute('rel', 'stylesheet')
|
||||
element.setAttribute('type', 'text/css')
|
||||
element.setAttribute('href', Froala.files[i].url)
|
||||
} else {
|
||||
element = document.createElement('script')
|
||||
element.setAttribute('type', 'text/javascript')
|
||||
element.setAttribute('src', Froala.files[i].url)
|
||||
}
|
||||
element.onload = onload
|
||||
head.insertBefore(element, head.firstChild)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = Froala
|
113
app/admin/pages.js
Normal file
113
app/admin/pages.js
Normal file
|
@ -0,0 +1,113 @@
|
|||
const Page = require('../api/page')
|
||||
const Dialogue = require('../widgets/dialogue')
|
||||
|
||||
const AdminPages = {
|
||||
parseTree: function(pages) {
|
||||
let map = new Map()
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
pages[i].children = []
|
||||
map.set(pages[i].id, pages[i])
|
||||
}
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
if (pages[i].parent_id && map.has(pages[i].parent_id)) {
|
||||
map.get(pages[i].parent_id).children.push(pages[i])
|
||||
pages.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return pages
|
||||
},
|
||||
|
||||
oninit: function(vnode) {
|
||||
this.loading = true
|
||||
this.error = ''
|
||||
this.pages = []
|
||||
this.removePage = null
|
||||
|
||||
document.title = 'Pages - Admin NFP Moe'
|
||||
|
||||
Page.getAllPages()
|
||||
.then(function(result) {
|
||||
vnode.state.pages = AdminPages.parseTree(result)
|
||||
})
|
||||
.catch(function(err) {
|
||||
vnode.state.error = err.message
|
||||
})
|
||||
.then(function() {
|
||||
vnode.state.loading = false
|
||||
m.redraw()
|
||||
})
|
||||
},
|
||||
|
||||
confirmRemovePage: function(vnode) {
|
||||
let removingPage = this.removePage
|
||||
this.removePage = null
|
||||
this.loading = true
|
||||
Page.removePage(removingPage, removingPage.id)
|
||||
.then(this.oninit.bind(this, vnode))
|
||||
.catch(function(err) {
|
||||
vnode.state.error = err.message
|
||||
vnode.state.loading = false
|
||||
m.redraw()
|
||||
})
|
||||
},
|
||||
|
||||
drawPage: function(vnode, page) {
|
||||
return [
|
||||
m('tr', [
|
||||
m('td', [
|
||||
page.parent_id ? m('span.subpage', ' - ') : null,
|
||||
m(m.route.Link, { href: '/admin/pages/' + page.id }, page.name),
|
||||
]),
|
||||
m('td', m(m.route.Link, { href: '/page/' + page.path }, '/page/' + page.path)),
|
||||
m('td.right', page.updated_at.replace('T', ' ').split('.')[0]),
|
||||
m('td.right', m('button', { onclick: function() { vnode.state.removePage = page } }, 'Remove')),
|
||||
]),
|
||||
].concat(page.children.map(AdminPages.drawPage.bind(this, vnode)))
|
||||
},
|
||||
|
||||
view: function(vnode) {
|
||||
return [
|
||||
(this.loading ?
|
||||
m('div.loading-spinner')
|
||||
: m('div.admin-wrapper', [
|
||||
m('div.admin-actions', [
|
||||
m('span', 'Actions:'),
|
||||
m(m.route.Link, { href: '/admin/pages/add' }, 'Create new page'),
|
||||
]),
|
||||
m('article.editpage', [
|
||||
m('header', m('h1', 'All pages')),
|
||||
m('div.error', {
|
||||
hidden: !this.error,
|
||||
onclick: function() { vnode.state.error = '' },
|
||||
}, this.error),
|
||||
m('table', [
|
||||
m('thead',
|
||||
m('tr', [
|
||||
m('th', 'Title'),
|
||||
m('th', 'Path'),
|
||||
m('th.right', 'Updated'),
|
||||
m('th.right', 'Actions'),
|
||||
])
|
||||
),
|
||||
m('tbody', this.pages.map(AdminPages.drawPage.bind(this, vnode))),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
),
|
||||
m(Dialogue, {
|
||||
hidden: vnode.state.removePage === null,
|
||||
title: 'Delete ' + (vnode.state.removePage ? vnode.state.removePage.name : ''),
|
||||
message: 'Are you sure you want to remove "' + (vnode.state.removePage ? vnode.state.removePage.name : '') + '" (' + (vnode.state.removePage ? vnode.state.removePage.path : '') + ')',
|
||||
yes: 'Remove',
|
||||
yesclass: 'alert',
|
||||
no: 'Cancel',
|
||||
noclass: 'cancel',
|
||||
onyes: this.confirmRemovePage.bind(this, vnode),
|
||||
onno: function() { vnode.state.removePage = null },
|
||||
}),
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = AdminPages
|
66
app/admin/pages.scss
Normal file
66
app/admin/pages.scss
Normal file
|
@ -0,0 +1,66 @@
|
|||
|
||||
article.editpage {
|
||||
text-align: center;
|
||||
background: white;
|
||||
padding: 0 0 20px;
|
||||
|
||||
header {
|
||||
padding: 10px;
|
||||
background: $secondary-bg;
|
||||
|
||||
h1 {
|
||||
color: $secondary-fg;
|
||||
}
|
||||
|
||||
a {
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
margin-left: 10px;
|
||||
color: $secondary-light-fg;
|
||||
}
|
||||
}
|
||||
|
||||
fileupload {
|
||||
margin: 0 0 20px;
|
||||
|
||||
.inside {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
fileupload.cover {
|
||||
align-self: center;
|
||||
min-width: 178px;
|
||||
|
||||
.display {
|
||||
background-size: auto 100%;
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 0 40px 20px;
|
||||
|
||||
textarea {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
& > .loading-spinner {
|
||||
width: 240px;
|
||||
height: 50px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
table span.subpage {
|
||||
padding: 0 5px;
|
||||
}
|
49
app/admin/staff.scss
Normal file
49
app/admin/staff.scss
Normal file
|
@ -0,0 +1,49 @@
|
|||
|
||||
article.editstaff {
|
||||
text-align: center;
|
||||
background: white;
|
||||
padding: 0 0 20px;
|
||||
|
||||
header {
|
||||
padding: 10px;
|
||||
background: $secondary-bg;
|
||||
|
||||
h1 {
|
||||
color: $secondary-fg;
|
||||
}
|
||||
|
||||
a {
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
margin-left: 10px;
|
||||
color: $secondary-light-fg;
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 0 40px 20px;
|
||||
|
||||
textarea {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
& > .loading-spinner {
|
||||
width: 240px;
|
||||
height: 50px;
|
||||
position: relative;
|
||||
|
||||
&.full {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
const Dialogue = require('./dialogue')
|
||||
const Pages = require('../paginator')
|
||||
const Staff = require('../api/staff')
|
||||
const Dialogue = require('../widgets/dialogue')
|
||||
const Pages = require('../widgets/pages')
|
||||
|
||||
const AdminStaffList = {
|
||||
/*
|
||||
oninit: function(vnode) {
|
||||
this.error = ''
|
||||
this.lastpage = m.route.param('page') || '1'
|
||||
|
@ -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.name)),
|
||||
m('td', m(m.route.Link, { href: '/admin/staff/' + item.id }, item.fullname)),
|
||||
m('td', item.email),
|
||||
m('td.right', AdminStaffList.getLevel(item.rank)),
|
||||
m('td.right', AdminStaffList.getLevel(item.level)),
|
||||
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.name : '') + '" (' + (vnode.state.removeStaff ? vnode.state.removeStaff.email : '') + ')',
|
||||
message: 'Are you sure you want to remove "' + (vnode.state.removeStaff ? vnode.state.removeStaff.fullname : '') + '" (' + (vnode.state.removeStaff ? vnode.state.removeStaff.email : '') + ')',
|
||||
yes: 'Remove',
|
||||
yesclass: 'alert',
|
||||
no: 'Cancel',
|
||||
|
@ -104,7 +104,7 @@ const AdminStaffList = {
|
|||
onno: function() { vnode.state.removeStaff = null },
|
||||
}),
|
||||
]
|
||||
},*/
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = AdminStaffList
|
64
app/api/article.js
Normal file
64
app/api/article.js
Normal file
|
@ -0,0 +1,64 @@
|
|||
const common = require('./common')
|
||||
|
||||
exports.createArticle = function(body) {
|
||||
return common.sendRequest({
|
||||
method: 'POST',
|
||||
url: '/api/articles',
|
||||
body: body,
|
||||
})
|
||||
}
|
||||
|
||||
exports.updateArticle = function(id, body) {
|
||||
return common.sendRequest({
|
||||
method: 'PUT',
|
||||
url: '/api/articles/' + id,
|
||||
body: body,
|
||||
})
|
||||
}
|
||||
|
||||
exports.getAllArticles = function() {
|
||||
return common.sendRequest({
|
||||
method: 'GET',
|
||||
url: '/api/articles?includes=parent',
|
||||
})
|
||||
}
|
||||
|
||||
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 common.sendRequest({
|
||||
method: 'GET',
|
||||
url: '/api/pages/' + pageId + '/articles?includes=' + includes.join(','),
|
||||
})
|
||||
}
|
||||
|
||||
exports.getArticle = function(id) {
|
||||
return common.sendRequest({
|
||||
method: 'GET',
|
||||
url: '/api/articles/' + id + '?includes=media,parent,banner,files',
|
||||
})
|
||||
}
|
||||
|
||||
exports.removeArticle = function(article, id) {
|
||||
return common.sendRequest({
|
||||
method: 'DELETE',
|
||||
url: '/api/articles/' + id,
|
||||
})
|
||||
}
|
46
app/api/article.p.js
Normal file
46
app/api/article.p.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
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',
|
||||
})
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
const Authentication = require('./authentication')
|
||||
const Authentication = require('../authentication')
|
||||
|
||||
exports.sendRequest = function(options, isPagination) {
|
||||
let token = Authentication.getToken()
|
||||
|
@ -41,7 +41,7 @@ exports.sendRequest = function(options, isPagination) {
|
|||
|
||||
return m.request(options)
|
||||
.catch(function (error) {
|
||||
if (error.status === 403) {
|
||||
if (error.code === 403) {
|
||||
Authentication.clearToken()
|
||||
m.route.set('/login', { redirect: m.route.get() })
|
||||
}
|
12
app/api/file.js
Normal file
12
app/api/file.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
const common = require('./common')
|
||||
|
||||
exports.uploadFile = function(articleId, file) {
|
||||
let formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return common.sendRequest({
|
||||
method: 'POST',
|
||||
url: '/api/articles/' + articleId + '/file',
|
||||
body: formData,
|
||||
})
|
||||
}
|
17
app/api/media.js
Normal file
17
app/api/media.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
const common = require('./common')
|
||||
|
||||
exports.uploadMedia = function(file, height) {
|
||||
let formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
let extra = ''
|
||||
if (height) {
|
||||
extra = '?height=' + height
|
||||
}
|
||||
|
||||
return common.sendRequest({
|
||||
method: 'POST',
|
||||
url: '/api/media' + extra,
|
||||
body: formData,
|
||||
})
|
||||
}
|
99
app/api/page.js
Normal file
99
app/api/page.js
Normal file
|
@ -0,0 +1,99 @@
|
|||
const common = require('./common')
|
||||
|
||||
const Tree = window.__nfptree || []
|
||||
|
||||
exports.Tree = Tree
|
||||
|
||||
exports.createPage = function(body) {
|
||||
return common.sendRequest({
|
||||
method: 'POST',
|
||||
url: '/api/pages',
|
||||
body: body,
|
||||
}).then(function(res) {
|
||||
res.children = []
|
||||
if (!res.parent_id) {
|
||||
Tree.push(res)
|
||||
} else {
|
||||
for (let i = 0; i < Tree.length; i++) {
|
||||
if (Tree[i].id === res.parent_id) {
|
||||
Tree[i].children.push(res)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
})
|
||||
}
|
||||
|
||||
exports.getTree = function() {
|
||||
return common.sendRequest({
|
||||
method: 'GET',
|
||||
url: '/api/pages?tree=true&includes=children&fields=id,name,path,children(id,name,path)',
|
||||
})
|
||||
}
|
||||
|
||||
exports.updatePage = function(id, body) {
|
||||
return common.sendRequest({
|
||||
method: 'PUT',
|
||||
url: '/api/pages/' + id,
|
||||
body: body,
|
||||
}).then(function(res) {
|
||||
for (let i = 0; i < Tree.length; i++) {
|
||||
if (Tree[i].id === res.id) {
|
||||
res.children = Tree[i].children
|
||||
Tree[i] = res
|
||||
break
|
||||
} else if (Tree[i].id === res.parent_id) {
|
||||
for (let x = 0; x < Tree[i].children.length; x++) {
|
||||
if (Tree[i].children[x].id === res.id) {
|
||||
res.children = Tree[i].children[x].children
|
||||
Tree[i].children[x] = res
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!res.children) {
|
||||
res.children = []
|
||||
}
|
||||
return res
|
||||
})
|
||||
}
|
||||
|
||||
exports.getAllPages = function() {
|
||||
return common.sendRequest({
|
||||
method: 'GET',
|
||||
url: '/api/pages',
|
||||
})
|
||||
}
|
||||
|
||||
exports.getPage = function(id) {
|
||||
return common.sendRequest({
|
||||
method: 'GET',
|
||||
url: '/api/pages/' + id + '?includes=media,banner',
|
||||
})
|
||||
}
|
||||
|
||||
exports.removePage = function(page, id) {
|
||||
return common.sendRequest({
|
||||
method: 'DELETE',
|
||||
url: '/api/pages/' + id,
|
||||
}).then(function() {
|
||||
for (let i = 0; i < Tree.length; i++) {
|
||||
if (Tree[i].id === page.id) {
|
||||
Tree.splice(i, 1)
|
||||
break
|
||||
} else if (Tree[i].id === page.parent_id) {
|
||||
for (let x = 0; x < Tree[i].children.length; x++) {
|
||||
if (Tree[i].children[x].id === page.id) {
|
||||
Tree[i].children.splice(x, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
19
app/api/page.p.js
Normal file
19
app/api/page.p.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
const common = require('./common')
|
||||
|
||||
const Tree = window.__nfptree || []
|
||||
|
||||
exports.Tree = Tree
|
||||
|
||||
exports.getTree = function() {
|
||||
return common.sendRequest({
|
||||
method: 'GET',
|
||||
url: '/api/pagetree',
|
||||
})
|
||||
}
|
||||
|
||||
exports.getPage = function(id) {
|
||||
return common.sendRequest({
|
||||
method: 'GET',
|
||||
url: '/api/pages/' + id + '?includes=media,banner,children,parent',
|
||||
})
|
||||
}
|
16
app/api/pagination.js
Normal file
16
app/api/pagination.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
const parse = require('parse-link-header')
|
||||
const common = require('./common')
|
||||
|
||||
exports.fetchPage = function(url) {
|
||||
return common.sendRequest({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
}, true)
|
||||
.then(function(result) {
|
||||
return {
|
||||
data: result.data,
|
||||
links: parse(result.headers.link || ''),
|
||||
total: Number(result.headers.pagination_total || '0'),
|
||||
}
|
||||
})
|
||||
}
|
38
app/api/staff.js
Normal file
38
app/api/staff.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
const common = require('./common')
|
||||
|
||||
exports.createStaff = function(body) {
|
||||
return common.sendRequest({
|
||||
method: 'POST',
|
||||
url: '/api/staff',
|
||||
body: body,
|
||||
})
|
||||
}
|
||||
|
||||
exports.updateStaff = function(id, body) {
|
||||
return common.sendRequest({
|
||||
method: 'PUT',
|
||||
url: '/api/staff/' + id,
|
||||
body: body,
|
||||
})
|
||||
}
|
||||
|
||||
exports.getAllStaff = function() {
|
||||
return common.sendRequest({
|
||||
method: 'GET',
|
||||
url: '/api/staff',
|
||||
})
|
||||
}
|
||||
|
||||
exports.getStaff = function(id) {
|
||||
return common.sendRequest({
|
||||
method: 'GET',
|
||||
url: '/api/staff/' + id,
|
||||
})
|
||||
}
|
||||
|
||||
exports.removeStaff = function(id) {
|
||||
return common.sendRequest({
|
||||
method: 'DELETE',
|
||||
url: '/api/staff/' + id,
|
||||
})
|
||||
}
|
274
app/app.scss
Normal file
274
app/app.scss
Normal file
|
@ -0,0 +1,274 @@
|
|||
@import './_common';
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
*, *:before, *:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body, h1, h2, h3, h4, h5, h6, p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100%;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@keyframes spinner-loader {
|
||||
to {transform: rotate(360deg);}
|
||||
}
|
||||
|
||||
.loading-spinner:before {
|
||||
content: '';
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: -10px;
|
||||
margin-left: -10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ccc;
|
||||
border-top-color: #333;
|
||||
animation: spinner-loader .6s linear infinite;
|
||||
}
|
||||
|
||||
.maincontainer {
|
||||
background: $main-bg;
|
||||
color: $main-fg;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 2;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.8em;
|
||||
color: $secondary-dark-bg;
|
||||
font-weight: bold;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-wrapper {
|
||||
flex-grow: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $border;
|
||||
padding: 40px 0;
|
||||
|
||||
.error {
|
||||
border: 2px dashed $secondary-dark-bg;
|
||||
padding: 10px 20px;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 2;
|
||||
padding: 20px;
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h1 {
|
||||
font-size: 1.4em;
|
||||
color: $title-fg;
|
||||
flex-grow: 2;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.8em;
|
||||
color: $meta-fg;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h5 {
|
||||
font-size: 1.0em;
|
||||
font-weight: bold;
|
||||
color: $title-fg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
label {
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
font-size: 0.8em;
|
||||
padding: 5px 0 3px;
|
||||
}
|
||||
|
||||
input[type=text],
|
||||
input[type=password],
|
||||
input[type=datetime-local],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
font-size: 1em;
|
||||
padding: 0.5em;
|
||||
margin: 0 0 0.5em;
|
||||
background: $input-bg;
|
||||
border: 1px solid $input-border;
|
||||
color: $input-fg;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: $secondary-bg;
|
||||
}
|
||||
}
|
||||
|
||||
input[type=submit],
|
||||
button.submit {
|
||||
align-self: center;
|
||||
padding: 0.5em;
|
||||
margin: 0.5em 0;
|
||||
min-width: 150px;
|
||||
border: 1px solid $secondary-bg;
|
||||
background: $secondary-light-bg;
|
||||
color: $secondary-light-fg;
|
||||
height: 31px;
|
||||
}
|
||||
|
||||
button.submit::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a, button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button::-moz-focus-inner,
|
||||
input[type="button"]::-moz-focus-inner,
|
||||
input[type="submit"]::-moz-focus-inner,
|
||||
input[type="reset"]::-moz-focus-inner {
|
||||
padding: 0 !important;
|
||||
border: 0 none !important;
|
||||
}
|
||||
|
||||
@import 'froala';
|
||||
|
||||
@import 'menu/menu';
|
||||
@import 'footer/footer';
|
||||
@import 'login/login';
|
||||
@import 'widgets/common';
|
||||
@import 'pages/page';
|
||||
@import 'article/article';
|
||||
@import 'frontpage/frontpage';
|
||||
|
||||
.darkmodeon {
|
||||
.maincontainer {
|
||||
background: $dark_main-bg;
|
||||
color: $dark_main-fg;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $dark_secondary-dark-bg;
|
||||
}
|
||||
|
||||
.fr-view blockquote {
|
||||
border-left-color: $dark_main-fg;
|
||||
color: $dark_main-fg;
|
||||
}
|
||||
|
||||
article.article,
|
||||
article.login,
|
||||
article.page {
|
||||
header {
|
||||
h1 {
|
||||
// color: $dark_title-fg;
|
||||
}
|
||||
|
||||
span {
|
||||
color: $dark_meta-fg;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
h5 {
|
||||
color: $dark_title-fg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login form {
|
||||
input[type=text],
|
||||
input[type=password],
|
||||
select,
|
||||
textarea {
|
||||
background: $dark_input-bg;
|
||||
border: 1px solid $dark_input-border;
|
||||
color: $dark_input-fg;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: $dark_secondary-bg;
|
||||
}
|
||||
}
|
||||
|
||||
input[type=submit] {
|
||||
border: 1px solid $dark_secondary-bg;
|
||||
background: $dark_secondary-light-bg;
|
||||
color: $dark_secondary-light-fg;
|
||||
|
||||
&:hover {
|
||||
background: $dark_secondary-dark-bg;
|
||||
color: $dark_secondary-dark-fg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fr-view {
|
||||
a { color: $dark_secondary-dark-bg; }
|
||||
}
|
||||
}
|
163
app/article/article.js
Normal file
163
app/article/article.js
Normal file
|
@ -0,0 +1,163 @@
|
|||
const m = require('mithril')
|
||||
const ApiArticle = require('../api/article.p')
|
||||
const Authentication = require('../authentication')
|
||||
const Fileinfo = require('../widgets/fileinfo')
|
||||
|
||||
const Article = {
|
||||
oninit: function(vnode) {
|
||||
this.error = ''
|
||||
this.lastarticle = m.route.param('article') || '1'
|
||||
this.showcomments = false
|
||||
|
||||
if (window.__nfpdata) {
|
||||
this.path = m.route.param('id')
|
||||
this.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)
|
||||
}
|
||||
},
|
||||
|
||||
view: function(vnode) {
|
||||
var deviceWidth = window.innerWidth
|
||||
var imagePath = ''
|
||||
|
||||
if (this.article.media) {
|
||||
var pixelRatio = window.devicePixelRatio || 1
|
||||
if ((deviceWidth < 800 && pixelRatio <= 1)
|
||||
|| (deviceWidth < 600 && pixelRatio > 1)) {
|
||||
imagePath = this.article.media.medium_url
|
||||
} else {
|
||||
imagePath = this.article.media.large_url
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
this.loading ?
|
||||
m('article.article', m('div.loading-spinner'))
|
||||
: this.error
|
||||
? m('div.error-wrapper', m('div.error', {
|
||||
onclick: function() {
|
||||
vnode.state.error = ''
|
||||
vnode.state.fetchArticle(vnode)
|
||||
},
|
||||
}, '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)),
|
||||
m('.fr-view', [
|
||||
this.article.media
|
||||
? m('a.cover', {
|
||||
rel: 'noopener',
|
||||
href: this.article.media.link,
|
||||
}, m('img', { src: imagePath, alt: 'Cover image for ' + this.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) {
|
||||
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'),
|
||||
]),
|
||||
]),
|
||||
Authentication.currentUser
|
||||
? m('div.admin-actions', [
|
||||
m('span', 'Admin controls:'),
|
||||
m(m.route.Link, { href: '/admin/articles/' + this.article.id }, 'Edit article'),
|
||||
])
|
||||
: null,
|
||||
this.showcomments
|
||||
? m('div.commentcontainer', [
|
||||
m('div#disqus_thread', { oncreate: function() {
|
||||
let fullhost = window.location.protocol + '//' + window.location.host
|
||||
/*eslint-disable */
|
||||
window.disqus_config = function () {
|
||||
this.page.url = fullhost + '/article/' + vnode.state.article.path
|
||||
this.page.identifier = 'article-' + vnode.state.article.id
|
||||
};
|
||||
(function() { // DON'T EDIT BELOW THIS LINE
|
||||
var d = document, s = d.createElement('script');
|
||||
s.src = 'https://nfp-moe.disqus.com/embed.js';
|
||||
s.setAttribute('data-timestamp', +new Date());
|
||||
(d.head || d.body).appendChild(s);
|
||||
})()
|
||||
/*eslint-enable */
|
||||
}}, m('div.loading-spinner')),
|
||||
])
|
||||
: m('button.opencomments', {
|
||||
onclick: function() { vnode.state.showcomments = true },
|
||||
}, 'Open comment discussion'),
|
||||
])
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = Article
|
||||
|
||||
/*
|
||||
<div id="disqus_thread"></div>
|
||||
<script>
|
||||
|
||||
/**
|
||||
* RECOMMENDED CONFIGURATION VARIABLES: EDIT AND UNCOMMENT THE SECTION BELOW TO INSERT DYNAMIC VALUES FROM YOUR PLATFORM OR CMS.
|
||||
* LEARN WHY DEFINING THESE VARIABLES IS IMPORTANT: https://disqus.com/admin/universalcode/#configuration-variables*/
|
||||
/*
|
||||
var disqus_config = function () {
|
||||
this.page.url = PAGE_URL; // Replace PAGE_URL with your page's canonical URL variable
|
||||
this.page.identifier = PAGE_IDENTIFIER; // Replace PAGE_IDENTIFIER with your page's unique identifier variable
|
||||
};
|
||||
/
|
||||
(function() { // DON'T EDIT BELOW THIS LINE
|
||||
var d = document, s = d.createElement('script');
|
||||
s.src = 'https://nfp-moe.disqus.com/embed.js';
|
||||
s.setAttribute('data-timestamp', +new Date());
|
||||
(d.head || d.body).appendChild(s);
|
||||
})();
|
||||
</script>
|
||||
<noscript>Please enable JavaScript to view the <a href="https://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>
|
||||
*/
|
151
app/article/article.scss
Normal file
151
app/article/article.scss
Normal file
|
@ -0,0 +1,151 @@
|
|||
|
||||
article.article {
|
||||
padding: 0;
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin: 20px 20px 0;
|
||||
padding: 10px;
|
||||
background: $secondary-bg;
|
||||
width: 100%;
|
||||
max-width: 1920px;
|
||||
align-self: center;
|
||||
|
||||
h1 {
|
||||
color: $secondary-fg;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
position: relative;
|
||||
flex-grow: 2;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.fr-view {
|
||||
margin: 0 20px;
|
||||
padding: 20px 20px 10px;
|
||||
width: calc(100% - 40px);
|
||||
max-width: 800px;
|
||||
flex: 2 0 0;
|
||||
align-self: center;
|
||||
background: $newsitem-bg;
|
||||
border-right: 1px solid #343536;
|
||||
border-left: 1px solid #343536;
|
||||
|
||||
a.cover img {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
fileinfo {
|
||||
font-size: 0.8em;
|
||||
|
||||
ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.opencomments {
|
||||
border: none;
|
||||
align-self: center;
|
||||
width: calc(100% - 40px);
|
||||
max-width: 300px;
|
||||
background: transparent;
|
||||
font-size: 1.2em;
|
||||
color: $secondary-dark-bg;
|
||||
cursor: pointer;
|
||||
height: 50px;
|
||||
margin: 0 0 30px;
|
||||
}
|
||||
|
||||
.commentcontainer {
|
||||
align-self: center;
|
||||
width: calc(100% - 40px);
|
||||
max-width: 800px;
|
||||
margin-bottom: 30px;
|
||||
min-height: 50px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.goback {
|
||||
width: calc(100% - 40px);
|
||||
max-width: 800px;
|
||||
align-self: center;
|
||||
padding: 30px 5px 0;
|
||||
margin-bottom: -10px;
|
||||
|
||||
a {
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
color: $secondary-dark-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.entrymeta {
|
||||
flex-grow: 2;
|
||||
font-size: 11px;
|
||||
color: $meta-fg;
|
||||
font-weight: bold;
|
||||
margin: 40px 0 0;
|
||||
|
||||
a {
|
||||
color: $secondary-dark-bg;
|
||||
margin: 0 4px;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 639px){
|
||||
article.article {
|
||||
.fr-view {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.darkmodeon {
|
||||
article.article {
|
||||
header {
|
||||
background: $dark_secondary-bg;
|
||||
|
||||
h1 {
|
||||
color: $dark_secondary-fg;
|
||||
}
|
||||
}
|
||||
|
||||
.fr-view {
|
||||
background: $dark_newsitem-bg;
|
||||
}
|
||||
|
||||
.opencomments {
|
||||
color: $dark_secondary-dark-bg;
|
||||
}
|
||||
|
||||
.goback a {
|
||||
color: $dark_secondary-dark-bg;
|
||||
}
|
||||
|
||||
.entrymeta {
|
||||
color: $dark_meta-fg;
|
||||
a {
|
||||
color: $dark_secondary-dark-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
const storageName = 'nfp_sites_logintoken'
|
||||
const storageName = 'logintoken'
|
||||
|
||||
const Authentication = {
|
||||
currentUser: null,
|
||||
|
@ -37,6 +37,4 @@ const Authentication = {
|
|||
|
||||
Authentication.updateToken(localStorage.getItem(storageName))
|
||||
|
||||
window.Authentication = Authentication
|
||||
|
||||
module.exports = Authentication
|
25
app/darkmode.js
Normal file
25
app/darkmode.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
const storageName = 'darkmode'
|
||||
|
||||
const Darkmode = {
|
||||
darkIsOn: false,
|
||||
|
||||
setDarkMode: function(setOn) {
|
||||
if (setOn) {
|
||||
localStorage.setItem(storageName, true)
|
||||
document.body.className = 'darkmodeon' + ' ' + (window.supportsavif ? 'avifsupport' : 'jpegonly')
|
||||
Darkmode.darkIsOn = true
|
||||
} else {
|
||||
localStorage.removeItem(storageName)
|
||||
document.body.className = 'daymode' + ' ' + (window.supportsavif ? 'avifsupport' : 'jpegonly')
|
||||
Darkmode.darkIsOn = false
|
||||
}
|
||||
},
|
||||
|
||||
isOn: function() {
|
||||
return Darkmode.darkIsOn
|
||||
},
|
||||
}
|
||||
|
||||
Darkmode.darkIsOn = localStorage.getItem(storageName)
|
||||
|
||||
module.exports = Darkmode
|
|
@ -1,7 +1,6 @@
|
|||
const m = require('mithril')
|
||||
const PageTree = require('./page_tree')
|
||||
const Authentication = require('./authentication')
|
||||
const common = require('./common')
|
||||
const Page = require('../api/page.p')
|
||||
const Authentication = require('../authentication')
|
||||
|
||||
const Footer = {
|
||||
oninit: function(vnode) {
|
||||
|
@ -10,35 +9,23 @@ const Footer = {
|
|||
|
||||
view: function() {
|
||||
return [
|
||||
m('div.first'),
|
||||
m('div.middle', [
|
||||
m('span', 'Sitemap'),
|
||||
m('div.footer-filler'),
|
||||
m('div.sitemap', [
|
||||
m('div', 'Sitemap'),
|
||||
m(m.route.Link, { class: 'root', href: '/' }, 'Home'),
|
||||
PageTree.Tree.map(function(page) {
|
||||
Page.Tree.map(function(page) {
|
||||
return [
|
||||
m(m.route.Link, { class: 'root', href: '/page/' + page.path }, page.name),
|
||||
(page.children
|
||||
(page.children.length
|
||||
? m('ul', page.children.map(function(subpage) {
|
||||
return m('li', m(m.route.Link, { class: 'child', href: '/page/' + subpage.path }, subpage.name))
|
||||
}))
|
||||
: null),
|
||||
]
|
||||
}),
|
||||
|
||||
m('ul', [
|
||||
m('li', !Authentication.currentUser
|
||||
? m(m.route.Link, { class: 'root', href: '/login' }, 'Login')
|
||||
: null),
|
||||
m('li', m('button',
|
||||
{ onclick: common.toggleDarkMode.bind(this) },
|
||||
common.darkIsOn ? 'Day mode' : 'Night mode'
|
||||
)),
|
||||
]),
|
||||
m('div.meta', [
|
||||
'Chibi designed and drawn by the amazing and talented ',
|
||||
m('a', { rel: 'noopener', href: 'https://shoritsu.xyz/', target: '_blank' }, 'SHORITSU'),
|
||||
'.',
|
||||
]),
|
||||
!Authentication.currentUser
|
||||
? m(m.route.Link, { class: 'root', href: '/login' }, 'Login')
|
||||
: null,
|
||||
m('div.meta', [
|
||||
'©'
|
||||
+ this.year
|
||||
|
@ -47,7 +34,7 @@ const Footer = {
|
|||
' (Fuck EU)',
|
||||
]),
|
||||
]),
|
||||
m('div.asuna.spritesheet'),
|
||||
m('div.footer-logo'),
|
||||
]
|
||||
},
|
||||
}
|
155
app/footer/footer.scss
Normal file
155
app/footer/footer.scss
Normal file
|
@ -0,0 +1,155 @@
|
|||
|
||||
footer {
|
||||
margin-top: 0px;
|
||||
border-top: 1px solid $border;
|
||||
display: flex;
|
||||
padding: 20px;
|
||||
background: $border;
|
||||
color: $border-fg;
|
||||
|
||||
.sitemap {
|
||||
display: flex;
|
||||
flex: 2 1 auto;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
padding: 0 20px;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: $border-fg-url;
|
||||
}
|
||||
|
||||
a.root {
|
||||
display: block;
|
||||
margin: 2px;
|
||||
padding: 2px 0 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 2px 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 0 10px;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid white;
|
||||
margin-bottom: 10px;
|
||||
min-width: 300px;
|
||||
|
||||
li {
|
||||
padding: 2px 5px;
|
||||
list-style-type: disc;
|
||||
list-style-position: inside;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-filler {
|
||||
width: 119px;
|
||||
flex: 0 0 119px;
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
background: center no-repeat transparent;
|
||||
background-size: contain;
|
||||
align-self: center;
|
||||
width: 119px;
|
||||
height: 150px;
|
||||
flex: 0 0 119px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
flex-grow: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 5px;
|
||||
align-items: flex-end;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
|
||||
a { margin: 0 3px; }
|
||||
}
|
||||
}
|
||||
|
||||
.darkmodeon {
|
||||
footer {
|
||||
border-top: 1px solid $dark_border;
|
||||
background: $dark_border;
|
||||
color: $dark_border-fg;
|
||||
|
||||
.sitemap {
|
||||
a {
|
||||
color: $dark_secondary-darker-fg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.daymode.jpegonly footer .footer-logo {
|
||||
background-image: url("/assets/img/tsun_small.jpg");
|
||||
}
|
||||
|
||||
.darkmodeon.jpegonly footer .footer-logo {
|
||||
background-image: url("/assets/img/dark_tsun_small.jpg");
|
||||
}
|
||||
|
||||
.avifsupport footer .footer-logo {
|
||||
background-image: url("/assets/img/tsun.avif");
|
||||
}
|
||||
|
||||
@media
|
||||
only screen and (-webkit-min-device-pixel-ratio: 2),
|
||||
only screen and ( min--moz-device-pixel-ratio: 2),
|
||||
only screen and ( -o-min-device-pixel-ratio: 2/1),
|
||||
only screen and ( min-device-pixel-ratio: 2),
|
||||
only screen and ( min-resolution: 192dpi),
|
||||
only screen and ( min-resolution: 2dppx) {
|
||||
.daymode.jpegonly .footer-logo {
|
||||
background-image: url("/assets/img/tsun.jpg");
|
||||
}
|
||||
|
||||
.darkmodeon.jpegonly .footer-logo {
|
||||
background-image: url("/assets/img/dark_tsun.jpg");
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer:coarse) {
|
||||
footer .sitemap a.root,
|
||||
footer .sitemap a.child {
|
||||
padding: 10px 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px){
|
||||
footer .footer-filler {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px){
|
||||
footer {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.footer-logo {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.sitemap {
|
||||
padding: 0px;
|
||||
|
||||
ul {
|
||||
align-self: stretch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer .sitemap a.root,
|
||||
footer .sitemap a.child {
|
||||
padding: 9px 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
208
app/froala.scss
Normal file
208
app/froala.scss
Normal file
|
@ -0,0 +1,208 @@
|
|||
.fr-view {
|
||||
word-wrap: break-word;
|
||||
|
||||
.clearfix::after {
|
||||
clear: both;
|
||||
display: block;
|
||||
content: "";
|
||||
height: 0
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6, p, dl, ol, ul {
|
||||
margin: 0 0 1em !important;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: solid 2px $main-fg;
|
||||
margin-left: 0;
|
||||
padding-left: 5px;
|
||||
color: $main-fg;
|
||||
}
|
||||
|
||||
.hide-by-clipping {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0
|
||||
}
|
||||
|
||||
img.fr-rounded,
|
||||
.fr-img-caption.fr-rounded img {
|
||||
border-radius: 10px;
|
||||
-moz-border-radius: 10px;
|
||||
-webkit-border-radius: 10px;
|
||||
-moz-background-clip: padding;
|
||||
-webkit-background-clip: padding-box;
|
||||
background-clip: padding-box
|
||||
}
|
||||
|
||||
img.fr-bordered,
|
||||
.fr-img-caption.fr-bordered img {
|
||||
border: solid 5px #CCC
|
||||
}
|
||||
|
||||
img.fr-bordered {
|
||||
-webkit-box-sizing: content-box;
|
||||
-moz-box-sizing: content-box;
|
||||
box-sizing: content-box
|
||||
}
|
||||
|
||||
.fr-img-caption.fr-bordered img {
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box
|
||||
}
|
||||
|
||||
span.fr-emoticon {
|
||||
font-weight: normal;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "NotoColorEmoji", "Segoe UI Symbol", "Android Emoji", "EmojiSymbols";
|
||||
display: inline;
|
||||
line-height: 0
|
||||
}
|
||||
|
||||
img {
|
||||
position: relative;
|
||||
max-width: 100%
|
||||
}
|
||||
|
||||
img.fr-dib {
|
||||
margin: 5px auto;
|
||||
display: block;
|
||||
float: none;
|
||||
vertical-align: top
|
||||
}
|
||||
|
||||
img.fr-dib.fr-fil {
|
||||
margin-left: 0;
|
||||
text-align: left
|
||||
}
|
||||
|
||||
img.fr-dib.fr-fir {
|
||||
margin-right: 0;
|
||||
text-align: right
|
||||
}
|
||||
|
||||
img.fr-dii {
|
||||
display: inline-block;
|
||||
float: none;
|
||||
vertical-align: bottom;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
max-width: calc(100% - (2 * 5px))
|
||||
}
|
||||
|
||||
img.fr-dii.fr-fil {
|
||||
float: left;
|
||||
margin: 5px 5px 5px 0;
|
||||
max-width: calc(100% - 5px)
|
||||
}
|
||||
|
||||
img.fr-dii.fr-fir {
|
||||
float: right;
|
||||
margin: 5px 0 5px 5px;
|
||||
max-width: calc(100% - 5px)
|
||||
}
|
||||
|
||||
span.fr-img-caption {
|
||||
position: relative;
|
||||
max-width: 100%
|
||||
}
|
||||
|
||||
span.fr-img-caption.fr-dib {
|
||||
margin: 5px auto;
|
||||
display: block;
|
||||
float: none;
|
||||
vertical-align: top
|
||||
}
|
||||
|
||||
span.fr-img-caption.fr-dib.fr-fil {
|
||||
margin-left: 0;
|
||||
text-align: left
|
||||
}
|
||||
|
||||
span.fr-img-caption.fr-dib.fr-fir {
|
||||
margin-right: 0;
|
||||
text-align: right
|
||||
}
|
||||
|
||||
span.fr-img-caption.fr-dii {
|
||||
display: inline-block;
|
||||
float: none;
|
||||
vertical-align: bottom;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
max-width: calc(100% - (2 * 5px))
|
||||
}
|
||||
|
||||
span.fr-img-caption.fr-dii.fr-fil {
|
||||
float: left;
|
||||
margin: 5px 5px 5px 0;
|
||||
max-width: calc(100% - 5px)
|
||||
}
|
||||
|
||||
span.fr-img-caption.fr-dii.fr-fir {
|
||||
float: right;
|
||||
margin: 5px 0 5px 5px;
|
||||
max-width: calc(100% - 5px)
|
||||
}
|
||||
|
||||
a.fr-strong {
|
||||
font-weight: 700
|
||||
}
|
||||
|
||||
a.fr-green {
|
||||
color: green
|
||||
}
|
||||
|
||||
.fr-img-caption {
|
||||
text-align: center
|
||||
}
|
||||
|
||||
.fr-img-caption .fr-img-wrap {
|
||||
padding: 0;
|
||||
display: inline-block;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
width: 100%
|
||||
}
|
||||
|
||||
.fr-img-caption .fr-img-wrap img {
|
||||
display: block;
|
||||
margin: auto;
|
||||
width: 100%
|
||||
}
|
||||
|
||||
.fr-img-caption .fr-img-wrap>span {
|
||||
margin: auto;
|
||||
display: block;
|
||||
padding: 5px 5px 10px;
|
||||
font-size: 14px;
|
||||
font-weight: initial;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
-webkit-opacity: 0.9;
|
||||
-moz-opacity: 0.9;
|
||||
opacity: 0.9;
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)";
|
||||
width: 100%;
|
||||
text-align: center
|
||||
}
|
||||
|
||||
a { color: $secondary-dark-bg; }
|
||||
dt { font-weight: bold; }
|
||||
ol { list-style-type: decimal; padding-left: 40px; }
|
||||
ul { list-style-type: disc; padding-left: 40px; }
|
||||
h1 { font-size: 1.8em; font-weight: bold; }
|
||||
h2 { font-size: 1.6em; font-weight: bold; }
|
||||
h3 { font-size: 1.4em; font-weight: bold; }
|
||||
h4 { font-size: 1.2em; font-weight: bold; }
|
||||
h5 { font-size: 1.0em; font-weight: bold; }
|
||||
h6 { font-size: 0.8em; font-weight: bold; }
|
||||
hr { width: 100%; }
|
||||
strong { font-weight: 700 }
|
||||
|
||||
}
|
156
app/frontpage/frontpage.js
Normal file
156
app/frontpage/frontpage.js
Normal file
|
@ -0,0 +1,156 @@
|
|||
const m = require('mithril')
|
||||
|
||||
const Page = require('../api/page.p')
|
||||
const Article = require('../api/article.p')
|
||||
const Pagination = require('../api/pagination')
|
||||
const Pages = require('../widgets/pages')
|
||||
const Newsitem = require('../widgets/newsitem')
|
||||
|
||||
const Frontpage = {
|
||||
oninit: function(vnode) {
|
||||
this.error = ''
|
||||
this.loading = false
|
||||
this.featured = null
|
||||
this.links = null
|
||||
|
||||
if (window.__nfpfeatured) {
|
||||
this.featured = window.__nfpfeatured
|
||||
}
|
||||
|
||||
if (window.__nfpdata
|
||||
&& window.__nfplinks) {
|
||||
this.links = window.__nfplinks
|
||||
this.articles = window.__nfpdata
|
||||
this.lastpage = m.route.param('page') || '1'
|
||||
window.__nfpdata = null
|
||||
window.__nfplinks = null
|
||||
|
||||
if (this.articles.length === 0) {
|
||||
m.route.set('/')
|
||||
} else {
|
||||
Frontpage.processFeatured(vnode, this.articles)
|
||||
}
|
||||
} else {
|
||||
this.fetchArticles(vnode)
|
||||
}
|
||||
},
|
||||
|
||||
onupdate: function(vnode) {
|
||||
if (this.lastpage !== (m.route.param('page') || '1')) {
|
||||
this.fetchArticles(vnode)
|
||||
m.redraw()
|
||||
}
|
||||
},
|
||||
|
||||
fetchArticles: function(vnode) {
|
||||
this.error = ''
|
||||
this.loading = true
|
||||
this.links = null
|
||||
this.articles = []
|
||||
this.lastpage = m.route.param('page') || '1'
|
||||
|
||||
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 Pagination.fetchPage(Article.getAllArticlesPagination({
|
||||
per_page: 10,
|
||||
page: this.lastpage,
|
||||
includes: ['parent', 'files', 'media', 'banner', 'staff'],
|
||||
}))
|
||||
.then(function(result) {
|
||||
vnode.state.articles = result.data
|
||||
vnode.state.links = result.links
|
||||
Frontpage.processFeatured(vnode, result.data)
|
||||
})
|
||||
.catch(function(err) {
|
||||
vnode.state.error = err.message
|
||||
})
|
||||
.then(function() {
|
||||
vnode.state.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) {
|
||||
var pixelRatio = window.devicePixelRatio || 1
|
||||
if (deviceWidth < 400 && pixelRatio <= 1) {
|
||||
bannerPath = window.supportsavif
|
||||
&& this.featured.banner.small_url_avif
|
||||
|| this.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
|
||||
} else {
|
||||
bannerPath = window.supportsavif
|
||||
&& this.featured.banner.large_url_avif
|
||||
|| this.featured.banner.large_url
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
(bannerPath
|
||||
? m(m.route.Link, {
|
||||
class: 'frontpage-banner',
|
||||
href: '/article/' + this.featured.path,
|
||||
style: { 'background-image': 'url("' + bannerPath + '")' },
|
||||
},
|
||||
this.featured.name
|
||||
)
|
||||
: null),
|
||||
m('frontpage', [
|
||||
m('aside.sidebar', [
|
||||
m('div.categories', [
|
||||
m('h4', 'Categories'),
|
||||
m('div',
|
||||
Page.Tree.map(function(page) {
|
||||
return [
|
||||
m(m.route.Link, { class: 'root', href: '/page/' + page.path }, page.name),
|
||||
(page.children.length
|
||||
? m('ul', page.children.map(function(subpage) {
|
||||
return m('li', m(m.route.Link, { class: 'child', href: '/page/' + subpage.path }, subpage.name))
|
||||
}))
|
||||
: null),
|
||||
]
|
||||
})
|
||||
),
|
||||
]),
|
||||
m('div.asunaside', {
|
||||
class: window.supportsavif ? 'avif' : 'jpeg'
|
||||
}),
|
||||
]),
|
||||
m('.frontpage-news', [
|
||||
(this.loading
|
||||
? m('div.loading-spinner')
|
||||
: null),
|
||||
this.articles.map(function(article) {
|
||||
return m(Newsitem, article)
|
||||
}),
|
||||
m(Pages, {
|
||||
base: '/',
|
||||
links: this.links,
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = Frontpage
|
197
app/frontpage/frontpage.scss
Normal file
197
app/frontpage/frontpage.scss
Normal file
|
@ -0,0 +1,197 @@
|
|||
.frontpage-banner {
|
||||
background-color: $meta-light-fg;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
height: 150px;
|
||||
width: calc(100% - 40px);
|
||||
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;
|
||||
text-decoration: none;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
frontpage {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
width: calc(100% - 40px);
|
||||
max-width: 1200px;
|
||||
flex: 2 0 0;
|
||||
|
||||
.frontpage-news {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 2;
|
||||
}
|
||||
|
||||
aside.sidebar {
|
||||
width: 250px;
|
||||
flex: 0 0 250px;
|
||||
align-self: flex-start;
|
||||
margin-right: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.categories {
|
||||
padding: 10px 10px 20px;
|
||||
margin-bottom: 20px;
|
||||
background: $newsitem-bg;
|
||||
border: $newsitem-border;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
padding: 0 5px 5px;
|
||||
font-weight: bold;
|
||||
margin: 0 0 10px;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
padding-left: 10px;
|
||||
list-style-type: disc;
|
||||
list-style-position: inside;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
padding: 2px 5px;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: $secondary-dark-bg;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
height: 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
newsitem {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.asunaside {
|
||||
display: block;
|
||||
width: 200px;
|
||||
height: 480px;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: top left;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
frontpage aside.sidebar {
|
||||
width: 200px;
|
||||
flex: 0 0 200px;
|
||||
|
||||
a {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px){
|
||||
frontpage {
|
||||
flex-direction: column;
|
||||
|
||||
aside.sidebar {
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
margin: 20px 0 30px;
|
||||
border-bottom: 1px solid $border;
|
||||
order: 2;
|
||||
|
||||
.categories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
h4 {
|
||||
align-self: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.daymode.jpegonly frontpage .asunaside {
|
||||
background-image: url("/assets/img/asuna_frontpage.jpg");
|
||||
}
|
||||
.daymode.avifsupport frontpage .asunaside {
|
||||
background-image: url("/assets/img/asuna_frontpage.avif?v=1");
|
||||
}
|
||||
|
||||
.darkmodeon.jpegonly frontpage .asunaside {
|
||||
background-image: url("/assets/img/dark_asuna_frontpage.jpg");
|
||||
}
|
||||
.darkmodeon.avifsupport frontpage .asunaside {
|
||||
background-image: url("/assets/img/dark_asuna_frontpage.avif?v=1");
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px){
|
||||
.frontpage-banner {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
frontpage {
|
||||
padding: 0 10px;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
frontpage aside.sidebar a {
|
||||
padding: 9px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer:coarse) {
|
||||
frontpage aside.sidebar a {
|
||||
padding: 9px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.darkmodeon {
|
||||
.frontpage-banner {
|
||||
background-color: $dark_meta-light-fg;
|
||||
}
|
||||
|
||||
frontpage {
|
||||
aside.sidebar {
|
||||
.categories {
|
||||
background: $dark_newsitem-bg;
|
||||
border: $dark_newsitem-border;
|
||||
}
|
||||
|
||||
h4 {
|
||||
border-bottom: 1px solid $dark_border;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $dark_secondary-dark-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
133
app/index.js
Normal file
133
app/index.js
Normal file
|
@ -0,0 +1,133 @@
|
|||
require('./polyfill')
|
||||
|
||||
const m = require('mithril')
|
||||
window.m = m
|
||||
|
||||
m.route.setOrig = m.route.set
|
||||
m.route.set = function(path, data, options){
|
||||
m.route.setOrig(path, data, options)
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
/*console.log('tree', window.__nfptree)
|
||||
console.log('featured', window.__nfpfeatured)
|
||||
console.log('data', window.__nfpdata)
|
||||
console.log('subdata', window.__nfpsubdata)
|
||||
console.log('links', window.__nfplinks)*/
|
||||
|
||||
m.route.linkOrig = m.route.link
|
||||
m.route.link = function(vnode){
|
||||
m.route.linkOrig(vnode)
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
const Authentication = require('./authentication')
|
||||
|
||||
m.route.prefix = ''
|
||||
window.adminRoutes = {}
|
||||
let loadingAdmin = false
|
||||
let loadedAdmin = false
|
||||
let loaded = 0
|
||||
let elements = []
|
||||
|
||||
const onLoaded = function() {
|
||||
loaded++
|
||||
if (loaded < 2) return
|
||||
|
||||
Authentication.setAdmin(Authentication.currentUser && Authentication.currentUser.level >= 10)
|
||||
loadedAdmin = true
|
||||
m.route.set(m.route.get())
|
||||
}
|
||||
|
||||
const onError = function() {
|
||||
elements.forEach(function(x) { x.remove() })
|
||||
loadedAdmin = loadingAdmin = false
|
||||
loaded = 0
|
||||
m.route.set('/logout')
|
||||
}
|
||||
|
||||
const loadAdmin = function(user) {
|
||||
if (loadingAdmin) {
|
||||
if (loadedAdmin) {
|
||||
Authentication.setAdmin(user && user.level >= 10)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!user || user.level < 10) return
|
||||
|
||||
loadingAdmin = true
|
||||
|
||||
let token = Authentication.getToken()
|
||||
let element = document.createElement('link')
|
||||
elements.push(element)
|
||||
element.setAttribute('rel', 'stylesheet')
|
||||
element.setAttribute('type', 'text/css')
|
||||
element.setAttribute('href', '/assets/admin.css?token=' + token)
|
||||
element.onload = onLoaded
|
||||
element.onerror = onError
|
||||
document.getElementsByTagName('head')[0].appendChild(element)
|
||||
|
||||
element = document.createElement('script')
|
||||
elements.push(element)
|
||||
element.setAttribute('type', 'text/javascript')
|
||||
element.setAttribute('src', '/assets/admin.js?token=' + token)
|
||||
element.onload = onLoaded
|
||||
element.onerror = onError
|
||||
document.body.appendChild(element)
|
||||
}
|
||||
|
||||
Authentication.addEvent(loadAdmin)
|
||||
if (Authentication.currentUser) {
|
||||
loadAdmin(Authentication.currentUser)
|
||||
}
|
||||
|
||||
const Menu = require('./menu/menu')
|
||||
const Footer = require('./footer/footer')
|
||||
const Frontpage = require('./frontpage/frontpage')
|
||||
const Login = require('./login/login')
|
||||
const Logout = require('./login/logout')
|
||||
const Page = require('./pages/page')
|
||||
const Article = require('./article/article')
|
||||
|
||||
const menuRoot = document.getElementById('nav')
|
||||
const mainRoot = document.getElementById('main')
|
||||
const footerRoot = document.getElementById('footer')
|
||||
|
||||
const Loader = {
|
||||
view: function() { return m('div.loading-spinner') },
|
||||
}
|
||||
const AdminResolver = {
|
||||
onmatch: function(args, requestedPath) {
|
||||
if (window.adminRoutes[args.path]) {
|
||||
return window.adminRoutes[args.path][args.id && 1 || 0]
|
||||
}
|
||||
return Loader
|
||||
},
|
||||
render: function(vnode) { return vnode },
|
||||
}
|
||||
|
||||
const allRoutes = {
|
||||
'/': Frontpage,
|
||||
'/login': Login,
|
||||
'/logout': Logout,
|
||||
'/page/:id': Page,
|
||||
'/article/:id': Article,
|
||||
'/admin/:path': AdminResolver,
|
||||
'/admin/:path/:id': AdminResolver,
|
||||
}
|
||||
|
||||
// Wait until we finish checking avif support, some views render immediately and will ask for this immediately before the callback gets called.
|
||||
|
||||
/*
|
||||
* imgsupport.js from leechy/imgsupport
|
||||
*/
|
||||
const AVIF = new Image();
|
||||
AVIF.onload = AVIF.onerror = function () {
|
||||
window.supportsavif = (AVIF.height === 2)
|
||||
document.body.className = document.body.className + ' ' + (window.supportsavif ? 'avifsupport' : 'jpegonly')
|
||||
|
||||
m.route(mainRoot, '/', allRoutes)
|
||||
m.mount(menuRoot, Menu)
|
||||
m.mount(footerRoot, Footer)
|
||||
}
|
||||
AVIF.src = 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A=';
|
98
app/login/login.js
Normal file
98
app/login/login.js
Normal file
|
@ -0,0 +1,98 @@
|
|||
const m = require('mithril')
|
||||
const Authentication = require('../authentication')
|
||||
const Api = require('../api/common')
|
||||
|
||||
const Login = {
|
||||
loading: false,
|
||||
redirect: '',
|
||||
error: '',
|
||||
|
||||
oninit: function(vnode) {
|
||||
Login.redirect = vnode.attrs.redirect || ''
|
||||
if (Authentication.currentUser) return m.route.set('/')
|
||||
Login.error = ''
|
||||
|
||||
this.username = ''
|
||||
this.password = ''
|
||||
},
|
||||
|
||||
oncreate: function() {
|
||||
if (Authentication.currentUser) return
|
||||
},
|
||||
|
||||
loginuser: function(vnode, e) {
|
||||
e.preventDefault()
|
||||
if (!this.username) {
|
||||
Login.error = 'Email is missing'
|
||||
} else if (!this.password) {
|
||||
Login.error = 'Password is missing'
|
||||
} else {
|
||||
Login.error = ''
|
||||
}
|
||||
if (Login.error) return
|
||||
|
||||
Login.loading = true
|
||||
|
||||
Api.sendRequest({
|
||||
method: 'POST',
|
||||
url: '/api/login/user',
|
||||
body: {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
},
|
||||
})
|
||||
.then(function(result) {
|
||||
Authentication.updateToken(result.token)
|
||||
m.route.set(Login.redirect || '/')
|
||||
})
|
||||
.catch(function(error) {
|
||||
Login.error = 'Error while logging into NFP! ' + error.message
|
||||
vnode.state.password = ''
|
||||
})
|
||||
.then(function () {
|
||||
Login.loading = false
|
||||
m.redraw()
|
||||
})
|
||||
},
|
||||
|
||||
view: function(vnode) {
|
||||
return [
|
||||
m('div.login-wrapper', [
|
||||
m('div.login-icon'),
|
||||
m('article.login', [
|
||||
m('header', [
|
||||
m('h1', 'NFP.moe login'),
|
||||
]),
|
||||
m('div.content', [
|
||||
m('h5', 'Please login to access restricted area'),
|
||||
Login.error ? m('div.error', Login.error) : null,
|
||||
Login.loading ? m('div.loading-spinner') : null,
|
||||
m('form', {
|
||||
hidden: Login.loading,
|
||||
onsubmit: this.loginuser.bind(this, vnode),
|
||||
}, [
|
||||
m('label', 'Email'),
|
||||
m('input', {
|
||||
type: 'text',
|
||||
value: this.username,
|
||||
oninput: function(e) { vnode.state.username = e.currentTarget.value },
|
||||
}),
|
||||
m('label', 'Password'),
|
||||
m('input', {
|
||||
type: 'password',
|
||||
value: this.password,
|
||||
oninput: function(e) { vnode.state.password = e.currentTarget.value },
|
||||
}),
|
||||
m('input', {
|
||||
type: 'submit',
|
||||
value: 'Login',
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = Login
|
60
app/login/login.scss
Normal file
60
app/login/login.scss
Normal file
|
@ -0,0 +1,60 @@
|
|||
@import '../_common';
|
||||
|
||||
.login-wrapper {
|
||||
flex-grow: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: $border;
|
||||
padding: 40px 0;
|
||||
|
||||
.login-icon {
|
||||
background: url('./img/login.png') center no-repeat transparent;
|
||||
background-size: contain;
|
||||
width: 106px;
|
||||
height: 130px;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
article.login {
|
||||
text-align: center;
|
||||
flex-grow: 0;
|
||||
border: 1px solid $title-fg;
|
||||
background: $main-bg;
|
||||
color: $main-fg;
|
||||
align-self: center;
|
||||
|
||||
.content {
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
padding: 20px 40px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 240px;
|
||||
height: 50px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
form {
|
||||
align-self: stretch;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.darkmodeon {
|
||||
.login-wrapper {
|
||||
background: $dark_border;
|
||||
}
|
||||
|
||||
article.login {
|
||||
border: 1px solid $dark_title-fg;
|
||||
background: $dark_main-bg;
|
||||
color: $dark_main-fg;
|
||||
}
|
||||
}
|
15
app/login/logout.js
Normal file
15
app/login/logout.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
const m = require('mithril')
|
||||
const Authentication = require('../authentication')
|
||||
|
||||
const Logout = {
|
||||
oninit: function() {
|
||||
Authentication.clearToken()
|
||||
m.route.set('/')
|
||||
},
|
||||
|
||||
view: function() {
|
||||
return m('div.loading-spinner')
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = Logout
|
95
app/menu/menu.js
Normal file
95
app/menu/menu.js
Normal file
|
@ -0,0 +1,95 @@
|
|||
const m = require('mithril')
|
||||
const Authentication = require('../authentication')
|
||||
const Darkmode = require('../darkmode')
|
||||
const Page = require('../api/page.p')
|
||||
|
||||
const Menu = {
|
||||
currentActive: 'home',
|
||||
error: '',
|
||||
loading: false,
|
||||
|
||||
onbeforeupdate: function() {
|
||||
let currentPath = m.route.get()
|
||||
if (currentPath === '/') Menu.currentActive = 'home'
|
||||
else if (currentPath === '/login') Menu.currentActive = 'login'
|
||||
else Menu.currentActive = currentPath
|
||||
},
|
||||
|
||||
oninit: function(vnode) {
|
||||
Menu.onbeforeupdate()
|
||||
|
||||
if (Page.Tree.length) return
|
||||
|
||||
Menu.loading = true
|
||||
|
||||
Page.getTree()
|
||||
.then(function(results) {
|
||||
Page.Tree.splice(0, Page.Tree.length)
|
||||
Page.Tree.push.apply(Page.Tree, results)
|
||||
})
|
||||
.catch(function(err) {
|
||||
Menu.error = err.message
|
||||
})
|
||||
.then(function() {
|
||||
Menu.loading = false
|
||||
m.redraw()
|
||||
})
|
||||
},
|
||||
|
||||
view: function() {
|
||||
return [
|
||||
m('div.top', [
|
||||
m(m.route.Link,
|
||||
{ href: '/', class: 'logo' },
|
||||
m('h2', 'NFP Moe')
|
||||
),
|
||||
m('aside', Authentication.currentUser ? [
|
||||
m('p', [
|
||||
'Welcome ' + Authentication.currentUser.email,
|
||||
m(m.route.Link, { href: '/logout' }, 'Logout'),
|
||||
(Darkmode.darkIsOn
|
||||
? m('button.dark', { onclick: Darkmode.setDarkMode.bind(Darkmode, false) }, 'Day mode')
|
||||
: m('button.dark', { onclick: Darkmode.setDarkMode.bind(Darkmode, true) }, 'Night mode')
|
||||
),
|
||||
]),
|
||||
(Authentication.isAdmin
|
||||
? m('div.adminlinks', [
|
||||
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'),
|
||||
])
|
||||
: (Authentication.currentUser.level > 10 ? m('div.loading-spinner') : null)
|
||||
),
|
||||
] : (Darkmode.darkIsOn
|
||||
? m('button.dark', { onclick: Darkmode.setDarkMode.bind(Darkmode, false) }, 'Day mode')
|
||||
: m('button.dark', { onclick: Darkmode.setDarkMode.bind(Darkmode, true) }, 'Night mode')
|
||||
)
|
||||
),
|
||||
]),
|
||||
m('nav', [
|
||||
m(m.route.Link, {
|
||||
href: '/',
|
||||
class: Menu.currentActive === 'home' ? 'active' : '',
|
||||
}, 'Home'),
|
||||
Menu.loading ? m('div.loading-spinner') : Page.Tree.map(function(page) {
|
||||
if (page.children.length) {
|
||||
return m('div.hassubmenu', [
|
||||
m(m.route.Link, {
|
||||
href: '/page/' + page.path,
|
||||
class: Menu.currentActive === ('/page/' + page.path) ? 'active' : '',
|
||||
}, page.name),
|
||||
])
|
||||
}
|
||||
return m(m.route.Link, {
|
||||
href: '/page/' + page.path,
|
||||
class: Menu.currentActive === ('/page/' + page.path) ? 'active' : '',
|
||||
}, page.name)
|
||||
}),
|
||||
]),
|
||||
Menu.error ? m('div.menuerror', Menu.error) : null,
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = Menu
|
188
app/menu/menu.scss
Normal file
188
app/menu/menu.scss
Normal file
|
@ -0,0 +1,188 @@
|
|||
@import '../_common';
|
||||
|
||||
#nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.top {
|
||||
background: $primary-dark-bg;
|
||||
color: $primary-dark-fg;
|
||||
padding: 0 10px 0 0;
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
|
||||
button.dark {
|
||||
background: transparent;
|
||||
color: $secondary-light-bg;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
a.logo {
|
||||
background: 25px center no-repeat transparent;
|
||||
background-size: auto 91px;
|
||||
padding-left: 120px;
|
||||
display: flex;
|
||||
color: $primary-dark-fg;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h2 {
|
||||
align-self: center;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
aside {
|
||||
flex-grow: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
padding: 10px 0;
|
||||
|
||||
p {
|
||||
font-size: 0.8em;
|
||||
color: $meta-light-fg;
|
||||
padding-bottom: 5px;
|
||||
|
||||
a {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
a, a:visited {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: $secondary-light-bg;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.4em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.adminlinks {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
max-width: 200px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
a {
|
||||
padding: 3px 5px;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
background: $primary-light-bg;
|
||||
color: $primary-light-fg;
|
||||
|
||||
.hassubmenu {
|
||||
flex-grow: 2;
|
||||
flex-basis: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
a, a:visited {
|
||||
flex-grow: 2;
|
||||
flex-basis: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: $primary-light-fg;
|
||||
padding: 10px;
|
||||
font-size: 0.9em;
|
||||
text-decoration: none;
|
||||
|
||||
&.active {
|
||||
border-bottom: 3px solid $secondary-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
position: relative;
|
||||
flex-grow: 2;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.menuerror {
|
||||
background: $primary-bg;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
color: $primary-fg-url;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.avifsupport #nav .top a.logo {
|
||||
background-image: url("/assets/img/logo.avif");
|
||||
}
|
||||
|
||||
.jpegonly #nav .top a.logo {
|
||||
background-image: url("/assets/img/logo_small.jpg");
|
||||
}
|
||||
|
||||
@media
|
||||
only screen and (-webkit-min-device-pixel-ratio: 2),
|
||||
only screen and ( min--moz-device-pixel-ratio: 2),
|
||||
only screen and ( -o-min-device-pixel-ratio: 2/1),
|
||||
only screen and ( min-device-pixel-ratio: 2),
|
||||
only screen and ( min-resolution: 192dpi),
|
||||
only screen and ( min-resolution: 2dppx) {
|
||||
.jpegonly #nav .top a.logo {
|
||||
background-image: url("/assets/img/logo.jpg");
|
||||
}
|
||||
}
|
||||
|
||||
.darkmodeon {
|
||||
#nav {
|
||||
.top {
|
||||
background: $dark_primary-dark-bg;
|
||||
color: $dark_primary-dark-fg;
|
||||
|
||||
a.logo {
|
||||
color: $dark_primary-dark-fg;
|
||||
}
|
||||
|
||||
aside {
|
||||
p {
|
||||
color: $dark_meta-light-fg;
|
||||
}
|
||||
|
||||
a, a:visited {
|
||||
color: $dark_secondary-light-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nav {
|
||||
background: $dark_primary-light-bg;
|
||||
color: $dark_primary-light-fg;
|
||||
|
||||
a, a:visited {
|
||||
color: $dark_primary-light-fg;
|
||||
|
||||
&.active {
|
||||
border-bottom: 3px solid $dark_secondary-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menuerror {
|
||||
background: $dark_primary-bg;
|
||||
color: $dark_primary-fg-url;
|
||||
}
|
||||
}
|
||||
}
|
184
app/pages/page.js
Normal file
184
app/pages/page.js
Normal file
|
@ -0,0 +1,184 @@
|
|||
const m = require('mithril')
|
||||
const ApiPage = require('../api/page.p')
|
||||
const Article = require('../api/article.p')
|
||||
const pagination = require('../api/pagination')
|
||||
const Authentication = require('../authentication')
|
||||
const Newsentry = require('../widgets/newsentry')
|
||||
const Pages = require('../widgets/pages')
|
||||
|
||||
const Page = {
|
||||
oninit: function(vnode) {
|
||||
this.error = ''
|
||||
this.lastpage = m.route.param('page') || '1'
|
||||
this.loadingnews = false
|
||||
|
||||
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
|
||||
|
||||
window.__nfpdata = null
|
||||
window.__nfpsubdata = null
|
||||
} else {
|
||||
this.fetchPage(vnode)
|
||||
}
|
||||
},
|
||||
|
||||
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.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 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
|
||||
})
|
||||
.catch(function(err) {
|
||||
vnode.state.error = err.message
|
||||
})
|
||||
.then(function() {
|
||||
vnode.state.loading = vnode.state.loadingnews = false
|
||||
m.redraw()
|
||||
})
|
||||
},
|
||||
|
||||
view: function(vnode) {
|
||||
var deviceWidth = window.innerWidth
|
||||
var pixelRatio = window.devicePixelRatio || 1
|
||||
var bannerPath = ''
|
||||
var imagePath = ''
|
||||
|
||||
if (this.page && this.page.banner) {
|
||||
if (deviceWidth < 400 && pixelRatio <= 1) {
|
||||
bannerPath = this.page.banner.small_url
|
||||
} else if ((deviceWidth < 800 && pixelRatio <= 1)
|
||||
|| (deviceWidth < 600 && pixelRatio > 1)) {
|
||||
bannerPath = this.page.banner.medium_url
|
||||
} else {
|
||||
bannerPath = this.page.banner.large_url
|
||||
}
|
||||
}
|
||||
|
||||
if (this.page && this.page.media) {
|
||||
if ((deviceWidth < 1000 && pixelRatio <= 1)
|
||||
|| (deviceWidth < 800 && pixelRatio > 1)) {
|
||||
imagePath = this.page.media.medium_url
|
||||
} else {
|
||||
imagePath = this.page.media.large_url
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
this.loading ?
|
||||
m('article.page', m('div.loading-spinner'))
|
||||
: 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)),
|
||||
m('.container', {
|
||||
class: this.page.children.length ? 'multi' : '',
|
||||
}, [
|
||||
this.page.children.length
|
||||
? m('aside.sidebar', [
|
||||
m('h4', 'View ' + this.page.name + ':'),
|
||||
this.page.children.map(function(page) {
|
||||
return m(m.route.Link, { href: '/page/' + page.path }, page.name)
|
||||
}),
|
||||
])
|
||||
: null,
|
||||
this.page.description
|
||||
? m('.fr-view', [
|
||||
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
|
||||
? m('aside.news', [
|
||||
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,
|
||||
])
|
||||
: this.news.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) {
|
||||
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.medium_url, alt: 'Cover image for ' + this.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'),
|
||||
])
|
||||
: null,
|
||||
])
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = Page
|
281
app/pages/page.scss
Normal file
281
app/pages/page.scss
Normal file
|
@ -0,0 +1,281 @@
|
|||
article.page {
|
||||
padding-bottom: 0;
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin: 20px 20px 0;
|
||||
padding: 10px;
|
||||
background: $secondary-bg;
|
||||
width: 100%;
|
||||
max-width: 1920px;
|
||||
align-self: center;
|
||||
|
||||
h1 {
|
||||
color: $secondary-fg;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
position: relative;
|
||||
flex-grow: 2;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.page-banner {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
height: 150px;
|
||||
width: 100%;
|
||||
max-width: 1920px;
|
||||
align-self: center;
|
||||
flex: 0 0 150px;
|
||||
}
|
||||
|
||||
.page-cover {
|
||||
margin: 0 0 20px;
|
||||
|
||||
&.single {
|
||||
margin: 0 20px 20px;
|
||||
padding: 0 20px;
|
||||
width: calc(100% - 40px);
|
||||
max-width: 800px;
|
||||
flex: 2 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.goback {
|
||||
width: 100%;
|
||||
max-width: 1050px;
|
||||
align-self: center;
|
||||
padding: 10px 5px 0;
|
||||
margin-bottom: -10px;
|
||||
|
||||
a {
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
color: $secondary-dark-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
aside.sidebar,
|
||||
aside.news {
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
// a {
|
||||
// display: inline-block;
|
||||
// padding-top: 5px;
|
||||
// text-decoration: none;
|
||||
// color: $secondary-dark-bg;
|
||||
// font-size: 14px;
|
||||
// font-weight: bold;
|
||||
// }
|
||||
}
|
||||
|
||||
.container {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
padding: 20px 0;
|
||||
width: 100%;
|
||||
max-width: 1050px;
|
||||
align-self: center;
|
||||
|
||||
background: $newsitem-bg;
|
||||
border-right: $newsitem-border;
|
||||
border-left: $newsitem-border;
|
||||
|
||||
&.multi {
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
flex-grow: 2;
|
||||
}
|
||||
}
|
||||
|
||||
aside.sidebar {
|
||||
width: 250px;
|
||||
flex: 0 0 250px;
|
||||
padding: 0 10px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
h4 {
|
||||
padding: 0 5px 5px;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
a {
|
||||
padding: 5px 5px 0px;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: $secondary-dark-bg;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.fr-view {
|
||||
margin: 0 20px;
|
||||
padding: 0 20px;
|
||||
width: calc(100% - 40px);
|
||||
max-width: 800px;
|
||||
flex: 2 0 0;
|
||||
|
||||
.page-cover {
|
||||
margin: 0 -10px 20px;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aside.news {
|
||||
border-top: 1px solid #ccc;
|
||||
margin-top: 20px;
|
||||
padding: 10px 10px;
|
||||
margin: 0 -10px;
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
|
||||
.loading-spinner {
|
||||
position: relative;
|
||||
height: 133px;
|
||||
}
|
||||
|
||||
newsentry {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
&.single {
|
||||
// max-width: 800px;
|
||||
flex: 2 0 0;
|
||||
padding: 0 20px 10px;
|
||||
border-top: none;
|
||||
margin-top: 0;
|
||||
align-self: flex-start;
|
||||
margin: 0;
|
||||
|
||||
& > h4 {
|
||||
padding: 0 5px 5px;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px){
|
||||
article.page aside.sidebar {
|
||||
width: 200px;
|
||||
flex: 0 0 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 639px){
|
||||
article.page {
|
||||
padding: 0;
|
||||
|
||||
.container {
|
||||
flex-direction: column !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
aside.sidebar {
|
||||
width: calc(100% - 80px);
|
||||
flex: 0 0 auto;
|
||||
margin: 0px 30px 30px;
|
||||
border-bottom: 1px solid $border;
|
||||
padding: 0 0 5px;
|
||||
}
|
||||
|
||||
.news.single .page-cover {
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 360px){
|
||||
article.page {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
aside {
|
||||
margin: 0px 0px 10px;
|
||||
}
|
||||
|
||||
.fr-view {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px){
|
||||
article.page aside.sidebar a {
|
||||
padding: 9px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer:coarse) {
|
||||
article.page aside.sidebar a {
|
||||
padding: 9px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.darkmodeon {
|
||||
article.page {
|
||||
header {
|
||||
background: $dark_secondary-bg;
|
||||
h1 {
|
||||
color: $dark_secondary-fg;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
background: $dark_newsitem-bg;
|
||||
border-right: $dark_newsitem-border;
|
||||
border-left: $dark_newsitem-border;
|
||||
}
|
||||
|
||||
aside.sidebar {
|
||||
h4 {
|
||||
border-bottom: 1px solid $dark_border;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $dark_secondary-dark-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.goback a {
|
||||
color: $dark_secondary-dark-bg;
|
||||
}
|
||||
}
|
||||
|
||||
aside.news {
|
||||
&.single {
|
||||
& > h4 {
|
||||
border-bottom: 1px solid $dark_border;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 639px){
|
||||
article.page aside.sidebar {
|
||||
border-bottom: 1px solid $dark_border;
|
||||
}
|
||||
}
|
||||
|
||||
.goback a {
|
||||
color: $dark_secondary-dark-bg;
|
||||
}
|
||||
}
|
8
app/polyfill.js
Normal file
8
app/polyfill.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
if (!String.prototype.endsWith) {
|
||||
String.prototype.endsWith = function(search, this_len) {
|
||||
if (this_len === undefined || this_len > this.length) {
|
||||
this_len = this.length;
|
||||
}
|
||||
return this.substring(this_len - search.length, this_len) === search;
|
||||
};
|
||||
}
|
133
app/widgets/admin.scss
Normal file
133
app/widgets/admin.scss
Normal file
|
@ -0,0 +1,133 @@
|
|||
|
||||
fileupload {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
|
||||
.showicon,
|
||||
.showbordericon,
|
||||
.display {
|
||||
flex-grow: 2;
|
||||
}
|
||||
|
||||
.showbordericon {
|
||||
border: 3px solid $title-fg;
|
||||
border-style: dashed;
|
||||
background-image: url('./img/upload.svg');
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 50px;
|
||||
}
|
||||
|
||||
.showicon {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-image: url('./img/upload.svg');
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.display {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #33333388;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
opacity: 0.01;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
width: calc(100% - 40px);
|
||||
max-width: 500px;
|
||||
color: $main-fg;
|
||||
|
||||
h2 {
|
||||
background: $secondary-dark-bg;
|
||||
color: $secondary-dark-fg;
|
||||
font-size: 1.5em;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid $secondary-dark-bg;
|
||||
background: transparent;
|
||||
color: $secondary-dark-bg;
|
||||
padding: 5px 15px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
button.alert {
|
||||
border-color: red;
|
||||
color: red;
|
||||
}
|
||||
|
||||
button.cancel {
|
||||
border-color: #999;
|
||||
color: #999;
|
||||
}
|
||||
}
|
321
app/widgets/common.scss
Normal file
321
app/widgets/common.scss
Normal file
|
@ -0,0 +1,321 @@
|
|||
|
||||
newsentry {
|
||||
display: flex;
|
||||
color: $meta-fg;
|
||||
font-size: 12px;
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
margin-bottom: 10px !important;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: $secondary-dark-bg;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
flex: 2 1 auto;
|
||||
padding: 0 5px 5px;
|
||||
|
||||
h3 {
|
||||
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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
|
||||
.trimmed {
|
||||
padding: 3px 0 5px 25px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 10px 0;
|
||||
padding-left: 0;
|
||||
list-style-type: disc;
|
||||
list-style-position: inside;
|
||||
|
||||
li {
|
||||
padding-left: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px){
|
||||
fileinfo,
|
||||
fileinfo.slim {
|
||||
padding: 8px 5px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer:coarse) {
|
||||
fileinfo,
|
||||
fileinfo.slim {
|
||||
padding: 8px 5px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
newsitem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 15px;
|
||||
background: $newsitem-bg;
|
||||
border: $newsitem-border;
|
||||
|
||||
.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;
|
||||
|
||||
picture {
|
||||
max-height: 400px;
|
||||
max-width: 400px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
a.nobg {
|
||||
height: 225px;
|
||||
width: 400px;
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.entrycontent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 2 1 auto;
|
||||
padding: 0 5px 5px;
|
||||
|
||||
&.extrapadding {
|
||||
padding: 0 15px 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;
|
||||
padding: 10px 0;
|
||||
|
||||
a {
|
||||
color: $secondary-dark-bg;
|
||||
margin: 0 5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px){
|
||||
newsitem a.cover {
|
||||
width: 300px;
|
||||
|
||||
picture {
|
||||
max-width: 300px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 639px){
|
||||
newsitem {
|
||||
a.cover {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
|
||||
picture {
|
||||
max-height: unset;
|
||||
max-width: unset;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.newsitemcontent {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.entrycontent.extrapadding {
|
||||
padding: 0 5px 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 440px){
|
||||
newsentry {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
a.cover {
|
||||
margin: 0 0 15px;
|
||||
width: 100%;
|
||||
|
||||
img {
|
||||
max-height: unset;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.darkmodeon {
|
||||
newsentry {
|
||||
color: $dark_meta-fg;
|
||||
|
||||
.title {
|
||||
a {
|
||||
color: $dark_secondary-dark-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.entrymeta {
|
||||
color: $dark_meta-fg;
|
||||
}
|
||||
}
|
||||
|
||||
fileinfo {
|
||||
.filetitle {
|
||||
a {
|
||||
color: $dark_secondary-dark-bg;
|
||||
border-right: 1px solid $dark_border;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newsitem {
|
||||
background: $dark_newsitem-bg;
|
||||
border: $dark_newsitem-border;
|
||||
|
||||
.title {
|
||||
background: $dark_secondary-bg;
|
||||
color: $dark_secondary-fg;
|
||||
}
|
||||
|
||||
.entrymeta {
|
||||
color: $dark_meta-fg;
|
||||
|
||||
a {
|
||||
color: $dark_secondary-dark-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pages {
|
||||
a {
|
||||
color: $dark_secondary-dark-bg;
|
||||
}
|
||||
}
|
||||
}
|
17
app/widgets/dialogue.js
Normal file
17
app/widgets/dialogue.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
const Dialogue = {
|
||||
view: function(vnode) {
|
||||
return m('div.floating-container', {
|
||||
hidden: vnode.attrs.hidden,
|
||||
}, m('dialogue', [
|
||||
m('h2', vnode.attrs.title),
|
||||
m('p', vnode.attrs.message),
|
||||
m('div.buttons', [
|
||||
m('button', { class: vnode.attrs.yesclass || '', onclick: vnode.attrs.onyes }, vnode.attrs.yes),
|
||||
m('button', { class: vnode.attrs.noclass || '', onclick: vnode.attrs.onno }, vnode.attrs.no),
|
||||
]),
|
||||
])
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = Dialogue
|
|
@ -55,19 +55,19 @@ const Fileinfo = {
|
|||
|
||||
view: function(vnode) {
|
||||
return m('fileinfo', { class: vnode.attrs.slim ? 'slim' : ''}, [
|
||||
m('p', [
|
||||
m('span', this.getPrefix(vnode) + ':'),
|
||||
m('div.filetitle', [
|
||||
m('span.prefix', this.getPrefix(vnode) + ':'),
|
||||
m('a', {
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
href: vnode.attrs.file.path,
|
||||
href: vnode.attrs.file.url,
|
||||
}, this.getDownloadName(vnode)),
|
||||
vnode.attrs.file.magnet
|
||||
? m('a', {
|
||||
href: vnode.attrs.file.magnet,
|
||||
}, 'Magnet')
|
||||
: null,
|
||||
this.getTitle(vnode),
|
||||
m('span', this.getTitle(vnode)),
|
||||
]),
|
||||
vnode.attrs.file.meta.torrent
|
||||
&& !vnode.attrs.slim
|
||||
|
@ -85,7 +85,6 @@ const Fileinfo = {
|
|||
&& vnode.attrs.file.meta.torrent.files.length > 4
|
||||
? m('div.trimmed', '...' + vnode.attrs.file.meta.torrent.files.length + ' files...')
|
||||
: null,
|
||||
vnode.children,
|
||||
])
|
||||
},
|
||||
}
|
69
app/widgets/fileupload.js
Normal file
69
app/widgets/fileupload.js
Normal file
|
@ -0,0 +1,69 @@
|
|||
const Media = require('../api/media')
|
||||
|
||||
const FileUpload = {
|
||||
uploadFile: 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)
|
||||
}
|
||||
})
|
||||
.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
|
||||
}
|
||||
},
|
||||
|
||||
oninit: function(vnode) {
|
||||
vnode.state.loading = false
|
||||
vnode.state.error = ''
|
||||
},
|
||||
|
||||
view: function(vnode) {
|
||||
let media = vnode.attrs.media
|
||||
|
||||
return m('fileupload', {
|
||||
class: vnode.attrs.class || null,
|
||||
}, [
|
||||
m('div.error', {
|
||||
hidden: !vnode.state.error,
|
||||
}, vnode.state.error),
|
||||
(media
|
||||
? vnode.attrs.useimg
|
||||
? [ m('img', { src: media.large_url }), m('div.showicon')]
|
||||
: m('a.display.inside', {
|
||||
href: media.large_url,
|
||||
style: {
|
||||
'background-image': 'url("' + media.large_url + '")',
|
||||
},
|
||||
}, m('div.showicon'))
|
||||
: m('div.inside.showbordericon')
|
||||
),
|
||||
m('input', {
|
||||
accept: 'image/*',
|
||||
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
|
57
app/widgets/newsentry.js
Normal file
57
app/widgets/newsentry.js
Normal file
|
@ -0,0 +1,57 @@
|
|||
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) {
|
||||
var deviceWidth = window.innerWidth
|
||||
var pixelRatio = window.devicePixelRatio || 1
|
||||
var imagePath = ''
|
||||
|
||||
if (vnode.attrs.media) {
|
||||
if (deviceWidth > 440 || pixelRatio <= 1) {
|
||||
imagePath = vnode.attrs.media.small_url
|
||||
} else {
|
||||
imagePath = vnode.attrs.media.medium_url
|
||||
}
|
||||
}
|
||||
return m('newsentry', [
|
||||
imagePath
|
||||
? m(m.route.Link, {
|
||||
class: 'cover',
|
||||
href: '/article/' + vnode.attrs.path,
|
||||
}, m('picture', [
|
||||
m('source', { srcset:
|
||||
vnode.attrs.media.small_url + ''
|
||||
}),
|
||||
m('img', { src: imagePath, alt: 'Article image for ' + vnode.attrs.name }),
|
||||
]))
|
||||
: m('a.cover.nobg'),
|
||||
m('div.entrycontent', [
|
||||
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),
|
||||
]),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = Newsentry
|
70
app/widgets/newsitem.js
Normal file
70
app/widgets/newsitem.js
Normal file
|
@ -0,0 +1,70 @@
|
|||
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 '
|
||||
} else {
|
||||
this.srcsetAvif = null
|
||||
}
|
||||
this.coverSizes = '(max-width: 639px) calc(100vw - 40px), '
|
||||
+ '(max-width: 1000px) 300px, '
|
||||
+ '400px'
|
||||
}
|
||||
},
|
||||
|
||||
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(m.route.Link, {
|
||||
class: 'cover',
|
||||
href: '/article/' + vnode.attrs.path,
|
||||
},
|
||||
m('picture', [
|
||||
this.srcsetAvif ? m('source', {
|
||||
srcset: this.srcsetAvif,
|
||||
sizes: this.coverSizes,
|
||||
type: 'image/avif',
|
||||
}) : null,
|
||||
m('img', {
|
||||
srcset: this.srcsetJpeg,
|
||||
sizes: this.coverSizes,
|
||||
alt: 'Image for news item ' + vnode.attrs.name,
|
||||
src: vnode.attrs.media.small_url }),
|
||||
])
|
||||
)
|
||||
: null,
|
||||
m('div.entrycontent', {
|
||||
class: vnode.attrs.media ? '' : 'extrapadding',
|
||||
}, [
|
||||
(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, 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'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = Newsitem
|
38
app/widgets/pages.js
Normal file
38
app/widgets/pages.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
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
|
|
@ -1 +0,0 @@
|
|||
package-lock=false
|
|
@ -1,149 +0,0 @@
|
|||
import { parseArticles, parseArticle } from './util.mjs'
|
||||
import { uploadMedia, uploadFile, deleteFile } from '../media/upload.mjs'
|
||||
import { mediaToDatabase } from '../media/util.mjs'
|
||||
|
||||
export default class ArticleRoutes {
|
||||
constructor(opts = {}) {
|
||||
Object.assign(this, {
|
||||
uploadMedia: uploadMedia,
|
||||
uploadFile: uploadFile,
|
||||
deleteFile: deleteFile,
|
||||
requireAuth: opts.requireAuth,
|
||||
})
|
||||
}
|
||||
|
||||
register(server) {
|
||||
if (!this.requireAuth) {
|
||||
server.flaska.get('/api/articles/:path', this.getArticle.bind(this))
|
||||
}
|
||||
server.flaska.get('/api/auth/articles', server.authenticate(), this.auth_getAllArticles.bind(this))
|
||||
server.flaska.get('/api/auth/articles/:id', server.authenticate(), this.auth_getSingleArticle.bind(this))
|
||||
server.flaska.put('/api/auth/articles/:id', [
|
||||
server.authenticate(),
|
||||
server.formidable({ maxFileSize: 20 * 1024 * 1024, }),
|
||||
], this.auth_updateCreateSingleArticle.bind(this))
|
||||
server.flaska.delete('/api/auth/articles/:id', server.authenticate(), this.auth_removeSingleArticle.bind(this))
|
||||
}
|
||||
|
||||
/** GET: /api/articles/[path] */
|
||||
async getArticle(ctx, onlyReturn = false) {
|
||||
let res = await ctx.db.safeCallProc('article_get_single', [ctx.params.path])
|
||||
|
||||
if (onlyReturn) {
|
||||
return this.getArticle_resOutput(res)
|
||||
}
|
||||
|
||||
ctx.body = this.getArticle_resOutput(res)
|
||||
}
|
||||
|
||||
getArticle_resOutput(res) {
|
||||
return {
|
||||
article: parseArticle(res.results[0][0]),
|
||||
}
|
||||
}
|
||||
|
||||
/** 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') || 20, 100)
|
||||
])
|
||||
|
||||
let out = {
|
||||
articles: parseArticles(res.results[0]),
|
||||
total_articles: res.results[1][0].total_articles,
|
||||
}
|
||||
|
||||
ctx.body = out
|
||||
}
|
||||
|
||||
async private_getUpdateArticle(ctx, body = null, banner = null, media = null) {
|
||||
let params = [
|
||||
ctx.state.auth_token,
|
||||
ctx.params.id === '0' ? null : ctx.params.id
|
||||
]
|
||||
if (body) {
|
||||
params = params.concat([
|
||||
body.name,
|
||||
body.page_id === 'null' ? null : Number(body.page_id),
|
||||
body.path,
|
||||
body.content,
|
||||
new Date(body.publish_at),
|
||||
Number(body.admin_id),
|
||||
body.is_featured === 'true' ? 1 : 0,
|
||||
0,
|
||||
])
|
||||
params = params.concat(mediaToDatabase(banner, body.remove_banner === 'true'))
|
||||
params = params.concat(mediaToDatabase(media, body.remove_media === 'true'))
|
||||
}
|
||||
|
||||
let res = await ctx.db.safeCallProc('article_auth_get_update_create', params)
|
||||
|
||||
ctx.body = this.private_getUpdateArticle_resOutput(res)
|
||||
}
|
||||
|
||||
private_getUpdateArticle_resOutput(res) {
|
||||
return {
|
||||
article: parseArticle(res.results[0][0] || {}),
|
||||
staff: res.results[1],
|
||||
}
|
||||
}
|
||||
|
||||
/** GET: /api/auth/articles/:id */
|
||||
auth_getSingleArticle(ctx) {
|
||||
return this.private_getUpdateArticle(ctx)
|
||||
}
|
||||
|
||||
/** PUT: /api/auth/articles/:id */
|
||||
async auth_updateCreateSingleArticle(ctx) {
|
||||
let newBanner = null
|
||||
let newMedia = null
|
||||
|
||||
let promises = []
|
||||
|
||||
if (ctx.req.files.banner) {
|
||||
promises.push(
|
||||
this.uploadMedia(ctx.req.files.banner)
|
||||
.then(res => { newBanner = res })
|
||||
)
|
||||
}
|
||||
if (ctx.req.files.media) {
|
||||
promises.push(
|
||||
this.uploadMedia(ctx.req.files.media)
|
||||
.then(res => { newMedia = res })
|
||||
)
|
||||
}
|
||||
|
||||
if (ctx.req.body.media && ctx.req.body.media.filename && ctx.req.body.media.type && ctx.req.body.media.path && ctx.req.body.media.size) {
|
||||
newMedia = ctx.req.body.media
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
return this.private_getUpdateArticle(ctx, ctx.req.body, newBanner, newMedia)
|
||||
}
|
||||
|
||||
/** DELETE: /api/auth/articles/:id */
|
||||
async auth_removeSingleArticle(ctx) {
|
||||
let params = [
|
||||
ctx.state.auth_token,
|
||||
ctx.params.id,
|
||||
// Article data
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
1,
|
||||
]
|
||||
params = params.concat(mediaToDatabase(null, true))
|
||||
params = params.concat(mediaToDatabase(null, true))
|
||||
|
||||
await ctx.db.safeCallProc('article_auth_get_update_create', params)
|
||||
|
||||
ctx.status = 204
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
import { contentToBlocks, parseMediaAndBanner } from '../util.mjs'
|
||||
|
||||
export function parseArticles(articles) {
|
||||
for (let i = 0; i < articles.length; i++) {
|
||||
parseArticle(articles[i])
|
||||
}
|
||||
return articles
|
||||
}
|
||||
|
||||
export function parseArticle(article) {
|
||||
if (!article) {
|
||||
return null
|
||||
}
|
||||
article.content = contentToBlocks(article.content)
|
||||
parseMediaAndBanner(article)
|
||||
return article
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
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,
|
||||
})
|
||||
}
|
||||
|
||||
register(server) {
|
||||
server.flaska.post('/api/authentication/login', server.jsonHandler(), this.login.bind(this))
|
||||
}
|
||||
|
||||
/** GET: /api/authentication/login */
|
||||
async login(ctx) {
|
||||
let res = await ctx.db.safeCallProc('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
|
||||
}
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
import crypto from 'crypto'
|
||||
import { HttpError } from 'flaska'
|
||||
import { decode, encode } from '../util.mjs'
|
||||
import config from '../config.mjs'
|
||||
|
||||
export const RankLevels = {
|
||||
Normal: 1,
|
||||
Manager: 10,
|
||||
Admin: 100,
|
||||
}
|
||||
|
||||
const issuer = config.get('mssql:connectionUser')
|
||||
const secret = config.get('jwtsecret')
|
||||
|
||||
export function verifyValidToken(parts, minLevel) {
|
||||
if (parts.length !== 4) {
|
||||
throw new HttpError(401, 'Authentication token invalid')
|
||||
}
|
||||
|
||||
const hmac = crypto.createHmac('sha256', secret)
|
||||
hmac.update([parts[0], parts[1], parts[2]].join('.'))
|
||||
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')
|
||||
}
|
||||
|
||||
if (body.rank < minLevel) {
|
||||
throw new HttpError(401, 'User does not have access to this resource')
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
export function authenticate(minLevel = RankLevels.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('.')
|
||||
|
||||
ctx.state.auth_user = verifyValidToken(parts, minLevel)
|
||||
ctx.state.auth_token = [parts[0], parts[1], parts[2]].join('.')
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue