Many updates, made server smarter by giving data and added meta tags

master
Jonatan Nilsson 2019-10-01 17:18:20 +00:00
parent 0f8cd7ac1a
commit 5b0e9d1d2d
29 changed files with 381 additions and 153 deletions

View File

@ -22,7 +22,7 @@
"require-await": 0,
"array-callback-return": 2,
"block-scoped-var": 2,
"complexity": ["error", 20],
"complexity": ["error", 30],
"eqeqeq": [2, "smart"],
"no-else-return": ["error", { "allowElseIf": false }],
"no-extra-bind": 2,

View File

@ -14,7 +14,7 @@ export function accessChecks(opts = { }) {
export function restrict(level = orgAccess.Normal) {
return async (ctx, next) => {
if (!ctx.headers.authorization) {
if (!ctx.headers.authorization && !ctx.query.token) {
return ctx.throw(403, 'Authentication token was not found (did you forget to login?)')
}
@ -26,6 +26,8 @@ export function restrict(level = orgAccess.Normal) {
return ctx.throw(403, 'You do not have enough access to access this resource')
}
return next()
if (next) {
return next()
}
}
}

View File

@ -77,13 +77,13 @@ const Article = bookshelf.createModel({
})
},
getFrontpageArticles() {
getFrontpageArticles(page = 1) {
return this.query(qb => {
qb.orderBy('updated_at', 'DESC')
})
.fetchPage({
pageSize: 10,
page: 1,
page: page,
withRelated: ['files', 'media', 'banner'],
})
},

View File

@ -52,6 +52,15 @@ export default class Jwt {
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,

View File

@ -24,12 +24,12 @@ export default class Resizer {
.then(() => output)
}
createMedium(input) {
createMedium(input, height) {
let output = this.Media.getSubUrl(input, 'medium')
return this.sharp(input)
.resize(700, 700, {
fit: sharp.fit.inside,
.resize(700, height || 700, {
fit: height && sharp.fit.cover || sharp.fit.inside,
withoutEnlargement: true,
})
.jpeg({

View File

@ -19,8 +19,13 @@ export default class MediaRoutes {
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)
let mediumPath = await this.resize.createMedium(result.path, height)
let largePath = await this.resize.createLarge(result.path)
let token = this.jwt.signDirect({ site: config.get('upload:name') }, config.get('upload:secret'))

View File

@ -1,87 +1,15 @@
import { readFileSync } from 'fs'
import send from 'koa-send'
import dot from 'dot'
import defaults from './defaults.mjs'
import config from './config.mjs'
import Page from './page/model.mjs'
import Article from './article/model.mjs'
import access from './access/index.mjs'
import { restrict } from './access/middleware.mjs'
import { serveIndex } from './serveindex.mjs'
const body = readFileSync('./public/index.html').toString()
const bodyTemplate = dot.template(body)
async function sendIndex(ctx, path) {
let tree = null
let data = null
let links = null
try {
tree = (await Page.getTree()).toJSON()
tree.forEach(item => (
item.children = item.children.map(x => (
{ id: x.id, name: x.name, path: x.path }
))
))
if (path === '/') {
data = await Article.getFrontpageArticles()
if (data.pagination.rowCount > 10) {
links = {
current: { title: 'Page 1' },
next: { page: 2, title: 'Next' },
last: { page: Math.ceil(data.pagination.rowCount / 10), title: 'Last' },
}
} else {
links = {
current: { title: 'Page 1' },
}
}
data = data.toJSON().map(x => ({
id: x.id,
created_at: x.created_at,
path: x.path,
description: x.description,
name: x.name,
media: x.media && ({
medium_url: x.media.medium_url,
small_url: x.media.small_url,
}) || null,
banner: x.banner && ({
large_url: x.banner.large_url,
medium_url: x.banner.medium_url,
small_url: x.banner.small_url,
}) || null,
files: x.files && x.files.map(f => ({
filename: f.filename,
url: f.url,
magnet: f.magnet,
meta: f.meta.torrent && ({
torrent: {
files: f.meta.torrent.files.map(tf => ({
name: tf.name,
size: tf.size,
})),
},
}) || {},
})) || [],
}))
}
} catch (e) {
ctx.log.error(e)
}
ctx.body = bodyTemplate({
v: config.get('CIRCLECI_VERSION'),
tree: JSON.stringify(tree),
data: JSON.stringify(data),
links: JSON.stringify(links),
})
ctx.set('Content-Length', Buffer.byteLength(ctx.body))
ctx.set('Cache-Control', 'max-age=0')
ctx.set('Content-Type', 'text/html; charset=utf-8')
}
const restrictAdmin = restrict(access.Manager)
export function serve(docRoot, pathname, options = {}) {
options.root = docRoot
return (ctx, next) => {
return async (ctx, next) => {
let opts = defaults({}, options)
if (ctx.request.method === 'OPTIONS') return
@ -96,16 +24,25 @@ export function serve(docRoot, pathname, options = {}) {
|| filepath.endsWith('.js')
|| filepath.endsWith('.css')
|| filepath.endsWith('.svg')) {
opts = defaults({ maxage: 2592000 * 1000 }, opts)
if (filepath.indexOf('admin') === -1) {
opts = defaults({ maxage: 2592000 * 1000 }, opts)
}
}
if (filepath === '/index.html') {
return sendIndex(ctx, '/')
return serveIndex(ctx, '/')
}
if (filepath.indexOf('admin') >= 0
&& (filepath.indexOf('js') >= 0
|| filepath.indexOf('css') >= 0)) {
await restrictAdmin(ctx)
ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate')
}
return send(ctx, filepath, opts).catch((er) => {
if (er.code === 'ENOENT' && er.status === 404) {
return sendIndex(ctx)
return serveIndex(ctx, filepath)
// return send(ctx, '/index.html', options)
}
})

149
api/serveindex.mjs Normal file
View File

@ -0,0 +1,149 @@
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 './article/model.mjs'
const body = readFileSync('./public/index.html').toString()
const bodyTemplate = dot.template(body)
function mapArticle(x) {
return {
id: x.id,
created_at: x.created_at,
path: x.path,
description: x.description,
name: x.name,
media: x.media && ({
large_url: x.media.large_url,
medium_url: x.media.medium_url,
small_url: x.media.small_url,
}) || null,
banner: x.banner && ({
large_url: x.banner.large_url,
medium_url: x.banner.medium_url,
small_url: x.banner.small_url,
}) || null,
parent: x.parent && ({
id: x.parent.id,
name: x.parent.name,
path: x.parent.path,
}),
files: x.files && x.files.map(f => ({
filename: f.filename,
url: f.url,
magnet: f.magnet,
meta: f.meta.torrent && ({
torrent: {
files: f.meta.torrent.files.map(tf => ({
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 && ({
large_url: x.media.large_url,
medium_url: x.media.medium_url,
small_url: x.media.small_url,
}) || null,
banner: x.banner && ({
large_url: x.banner.large_url,
medium_url: x.banner.medium_url,
small_url: x.banner.small_url,
}) || 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 links = null
let image = '/assets/img/heart.jpg'
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()).toJSON()
tree.forEach(item => (
item.children = item.children.map(x => (
{ id: x.id, name: x.name, path: x.path }
))
))
if (path === '/') {
data = await Article.getFrontpageArticles(Number(ctx.query.page || '1'))
if (data.pagination.rowCount > 10) {
links = {
current: { title: 'Page 1' },
next: { page: 2, title: 'Next' },
last: { page: Math.ceil(data.pagination.rowCount / 10), title: 'Last' },
}
} else {
links = {
current: { title: 'Page 1' },
}
}
data = data.toJSON().map(mapArticle)
} else if (path.startsWith('/article/') || path.startsWith('/page/')) {
let id = path.split('/')[2]
if (id) {
let found
if (path.startsWith('/article/')) {
found = await Article.getSingle(id, ['media', 'parent', 'banner', 'files'])
found = mapArticle(found.toJSON())
data = found
} else {
found = await Page.getSingle(id, ['media', 'banner', 'children'])
found = mapPage(found.toJSON())
data = found
}
if (found.media) {
image = found.media.large_url
} else if (found.banner) {
image = found.banner.large_url
}
if (found.description) {
description = striptags(found.description)
}
if (found.parent) {
title = found.name + ' - ' + found.parent.name + ' - NFP Moe'
} else {
title = found.name + ' - NFP Moe'
}
}
}
} 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),
links: JSON.stringify(links),
image: image,
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')
}

View File

@ -5,11 +5,8 @@ const EditArticle = require('./admin/editarticle')
const AdminStaffList = require('./admin/stafflist')
const EditStaff = require('./admin/editstaff')
window.addAdminRoutes = [
['/admin/pages', AdminPages],
['/admin/pages/:key', EditPage],
['/admin/articles', AdminArticles],
['/admin/articles/:id', EditArticle],
['/admin/staff', AdminStaffList],
['/admin/staff/:id', EditStaff],
]
window.adminRoutes = {
pages: [AdminPages, EditPage],
articles: [AdminArticles, EditArticle],
staff: [AdminStaffList, EditStaff],
}

View File

@ -24,6 +24,8 @@ const AdminArticles = {
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: 10,
page: this.lastpage,

View File

@ -45,6 +45,9 @@ const EditArticle = {
onupdate: function(vnode) {
if (this.lastid !== m.route.param('id')) {
this.fetchArticle(vnode)
if (this.lastid === 'add') {
m.redraw()
}
}
},
@ -63,7 +66,6 @@ const EditArticle = {
files: [],
}
this.editedPath = false
this.froala = null
this.loadedFroala = Froala.loadedFroala
if (this.lastid !== 'add') {
@ -71,14 +73,23 @@ const EditArticle = {
.then(function(result) {
vnode.state.editedPath = true
vnode.state.article = result
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 {
document.title = 'Create Article - Admin NFP Moe'
if (vnode.state.froala) {
vnode.state.froala.html.set(this.article.description)
}
}
},
@ -216,6 +227,7 @@ const EditArticle = {
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'),

View File

@ -43,6 +43,7 @@ const EditPage = {
.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
@ -51,6 +52,8 @@ const EditPage = {
vnode.state.loading = false
m.redraw()
})
} else {
document.title = 'Create Page - Admin NFP Moe'
}
if (!this.loadedFroala) {

View File

@ -28,6 +28,7 @@ const EditStaff = {
.then(function(result) {
vnode.state.editedPath = true
vnode.state.staff = result
document.title = 'Editing: ' + result.fullname + ' - Admin NFP Moe'
})
.catch(function(err) {
vnode.state.error = err.message
@ -36,6 +37,8 @@ const EditStaff = {
vnode.state.loading = false
m.redraw()
})
} else {
document.title = 'Creating Staff Member - Admin NFP Moe'
}
},

View File

@ -24,6 +24,8 @@ const AdminPages = {
this.pages = []
this.removePage = null
document.title = 'Pages - Admin NFP Moe'
Page.getAllPages()
.then(function(result) {
vnode.state.pages = AdminPages.parseTree(result)

View File

@ -15,6 +15,8 @@ const AdminStaffList = {
fetchStaffs: function(vnode) {
this.loading = true
document.title = 'Staff members - Admin NFP Moe'
return Staff.getAllStaff()
.then(function(result) {
vnode.state.staff = result

View File

@ -1,12 +1,17 @@
const common = require('./common')
exports.uploadMedia = function(file) {
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',
url: '/api/media' + extra,
body: formData,
})
}

View File

@ -14,6 +14,6 @@ exports.getTree = function() {
exports.getPage = function(id) {
return common.sendRequest({
method: 'GET',
url: '/api/pages/' + id + '?includes=media,banner,children,news,news.media',
url: '/api/pages/' + id + '?includes=media,banner,children',
})
}

View File

@ -8,13 +8,18 @@ const Article = {
this.error = ''
this.lastarticle = m.route.param('article') || '1'
this.loadingnews = false
this.fetchArticle(vnode)
if (window.__nfpdata) {
this.path = m.route.param('id')
this.article = window.__nfpdata
window.__nfpdata = null
} else {
this.fetchArticle(vnode)
}
},
fetchArticle: function(vnode) {
this.path = m.route.param('id')
this.news = []
this.newslinks = null
this.article = {
id: 0,
name: '',
@ -29,6 +34,11 @@ const Article = {
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

View File

@ -101,7 +101,15 @@ only screen and ( min-resolution: 2dppx) {
@media (pointer:coarse) {
footer .sitemap a.root,
footer .sitemap a.child {
// padding: 10px 10px;
// display: inline-block;
padding: 10px 10px;
display: inline-block;
}
}
@media screen and (max-width: 480px){
footer .sitemap a.root,
footer .sitemap a.child {
padding: 9px 10px;
display: inline-block;
}
}

View File

@ -17,10 +17,15 @@ const Frontpage = {
&& window.__nfplinks) {
this.links = window.__nfplinks
this.articles = window.__nfpdata
this.lastpage = '1'
this.lastpage = m.route.param('page') || '1'
window.__nfpdata = null
window.__nfplinks = null
Frontpage.processFeatured(vnode, this.articles)
if (this.articles.length === 0) {
m.route.set('/')
} else {
Frontpage.processFeatured(vnode, this.articles)
}
} else {
this.fetchArticles(vnode)
}
@ -39,6 +44,12 @@ const Frontpage = {
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,
@ -59,6 +70,7 @@ const Frontpage = {
},
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 File

@ -143,6 +143,16 @@ frontpage {
margin: 0;
width: 100%;
}
frontpage aside.sidebar a {
padding: 9px 10px;
}
}
@media (pointer:coarse) {
frontpage aside.sidebar a {
padding: 9px 10px;
}
}
.darkmodeon {

View File

@ -1,51 +1,29 @@
const m = require('mithril')
window.m = m
m.route.prefix = ''
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 Authentication = require('./authentication')
const menuRoot = document.getElementById('nav')
const mainRoot = document.getElementById('main')
const footerRoot = document.getElementById('footer')
const allRoutes = {
'/': Frontpage,
'/login': Login,
'/logout': Logout,
'/page/:id': Page,
'/article/:id': Article,
}
m.route(mainRoot, '/', allRoutes)
m.mount(menuRoot, Menu)
m.mount(footerRoot, Footer)
m.route.prefix = ''
window.adminRoutes = {}
let loadingAdmin = false
let loadedAdmin = false
let loaded = 0
let elements = []
const onLoaded = function() {
loaded++
if (loaded < 2) return
if (window.addAdminRoutes) {
window.addAdminRoutes.forEach(function (item) {
allRoutes[item[0]] = item[1]
})
m.route(mainRoot, '/', allRoutes)
}
Authentication.setAdmin(Authentication.currentUser && Authentication.currentUser.level >= 10)
loadedAdmin = true
m.redraw()
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) {
@ -59,17 +37,22 @@ const loadAdmin = function(user) {
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')
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')
element.setAttribute('src', '/assets/admin.js?token=' + token)
element.onload = onLoaded
element.onerror = onError
document.body.appendChild(element)
}
@ -77,3 +60,42 @@ 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,
}
m.route(mainRoot, '/', allRoutes)
m.mount(menuRoot, Menu)
m.mount(footerRoot, Footer)

View File

@ -6,12 +6,12 @@ const Logout = {
Authentication.createGoogleScript()
.then(function() {
return new Promise(function (res) {
gapi.load('auth2', res);
gapi.load('auth2', res)
})
})
.then(function() { return gapi.auth2.init() })
.then(function() {
let auth2 = gapi.auth2.getAuthInstance();
let auth2 = gapi.auth2.getAuthInstance()
return auth2.signOut()
})
.then(function() {

View File

@ -11,7 +11,17 @@ const Page = {
this.error = ''
this.lastpage = m.route.param('page') || '1'
this.loadingnews = false
this.fetchPage(vnode)
if (window.__nfpdata) {
this.path = m.route.param('id')
this.page = window.__nfpdata
this.news = []
this.newslinks = null
window.__nfpdata = null
vnode.state.fetchArticles(vnode)
} else {
this.fetchPage(vnode)
}
},
fetchPage: function(vnode) {
@ -30,6 +40,7 @@ const Page = {
ApiPage.getPage(this.path)
.then(function(result) {
vnode.state.page = result
document.title = result.name + ' - NFP Moe'
})
.catch(function(err) {
vnode.state.error = err.message
@ -71,11 +82,26 @@ const Page = {
},
view: function(vnode) {
var deviceWidth = window.innerWidth
var bannerPath = ''
if (this.page && this.page.banner) {
var pixelRatio = window.devicePixelRatio || 1
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
}
}
return (
this.loading ?
m('div.loading-spinner')
: m('article.page', [
this.page.banner ? m('.div.page-banner', { style: { 'background-image': 'url("' + this.page.banner.url + '")' } } ) : null,
bannerPath ? m('.div.page-banner', { style: { 'background-image': 'url("' + bannerPath + '")' } } ) : null,
m('header', m('h1', this.page.name)),
m('.container', {
class: this.page.children.length ? 'multi' : '',
@ -90,7 +116,7 @@ const Page = {
: null,
this.page.description
? m('.fr-view', [
this.page.media ? m('img.page-cover', { src: this.page.media.url, alt: 'Cover image for ' + this.page.name } ) : null,
this.page.media ? m('img.page-cover', { src: this.page.media.medium_url, alt: 'Cover image for ' + this.page.name } ) : null,
m.trust(this.page.description),
this.news.length && this.page.description
? m('aside.news', [
@ -107,7 +133,7 @@ const Page = {
])
: this.news.length
? m('aside.news.single', [
this.page.media ? m('img.page-cover', { src: this.page.media.url, alt: 'Cover image for ' + this.page.name } ) : null,
this.page.media ? m('img.page-cover', { src: this.page.media.medium_url, 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)
@ -118,7 +144,7 @@ const Page = {
}),
])
: this.page.media
? m('img.page-cover.single', { src: this.page.media.url, alt: 'Cover image for ' + this.page.name } )
? m('img.page-cover.single', { src: this.page.media.medium_url, alt: 'Cover image for ' + this.page.name } )
: null,
]),
Authentication.currentUser

View File

@ -6,7 +6,7 @@ const FileUpload = {
vnode.state.updateError(vnode, '')
vnode.state.loading = true
Media.uploadMedia(event.target.files[0])
Media.uploadMedia(event.target.files[0], vnode.attrs.height || null)
.then(function(res) {
if (vnode.attrs.onupload) {
vnode.attrs.onupload(res)

View File

@ -66,7 +66,8 @@
"nconf": "^0.10.0",
"parse-torrent": "^7.0.1",
"pg": "^7.8.0",
"sharp": "^0.22.1"
"sharp": "^0.22.1",
"striptags": "^3.1.1"
},
"devDependencies": {
"browserify": "^16.2.3",

BIN
public/assets/img/heart.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
public/assets/img/heart.xcf Normal file

Binary file not shown.

View File

@ -2,9 +2,20 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>NFP Moe</title>
<title>{{=it.title}}</title>
<base href="/">
<meta name="description" content="{{=it.description}}">
<meta name="twitter:card" value="summary">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta property="og:type" content="website" />
<meta id="ogimage" property="og:image" content="{{=it.image}}" />
<meta property="og:description" content="{{=it.description}}" />
{{? it.image === '/assets/img/heart.jpg' }}
<meta id="ogimagewidth" property="og:image:width" content="400" />
<meta id="ogimageheight" property="og:image:height" content="500" />
{{?? true }}
{{? }}
<link rel="icon" type="image/png" href="/assets/img/favicon.png">
<link rel="Stylesheet" href="/assets/app.css?v={{=it.v}}" type="text/css" />
<link rel="preconnect" href="https://cdn-nfp.global.ssl.fastly.net" />