From 18c7c25eed0541f3a3de3cf6cc607ff6c34af91d Mon Sep 17 00:00:00 2001 From: Jonatan Nilsson Date: Wed, 20 Feb 2019 16:10:37 +0000 Subject: [PATCH] many updates --- .gitignore | 4 +- api/authentication/google.mjs | 14 +++++ api/authentication/routes.mjs | 39 ++++++++++++ api/jwt.mjs | 13 ++-- api/media/model.mjs | 56 +++++++++++++++++ api/media/multer.mjs | 15 +++++ api/media/resize.mjs | 38 ++++++++++++ api/media/routes.mjs | 43 +++++++++++++ api/media/upload.mjs | 73 ++++++++++++++++++++++ api/router.mjs | 15 +++-- app/_common.scss | 17 ++++++ app/admin/admin.scss | 41 +++++++++++++ app/admin/editcat.js | 27 ++++++++ app/admin/editcat.scss | 0 app/app.sass | 4 -- app/app.scss | 112 ++++++++++++++++++++++++++++++++++ app/authentication.js | 56 +++++++++++++++++ app/frontpage/frontpage.js | 12 ++++ app/index.js | 22 ++++++- app/login/login.js | 83 +++++++++++++++++++++++++ app/login/login.scss | 33 ++++++++++ app/login/logout.js | 28 +++++++++ app/menu/menu.js | 47 ++++++++++++++ app/menu/menu.scss | 65 ++++++++++++++++++++ app/widgets/common.scss | 0 app/widgets/fileupload.js | 26 ++++++++ config/config.default.json | 1 + nodemon.json | 3 + package.json | 7 ++- public/assets/img/favicon.png | Bin 0 -> 2515 bytes public/assets/img/logo.png | Bin 0 -> 11118 bytes public/index.html | 19 +++++- 32 files changed, 887 insertions(+), 26 deletions(-) create mode 100644 api/authentication/google.mjs create mode 100644 api/authentication/routes.mjs create mode 100644 api/media/model.mjs create mode 100644 api/media/multer.mjs create mode 100644 api/media/resize.mjs create mode 100644 api/media/routes.mjs create mode 100644 api/media/upload.mjs create mode 100644 app/_common.scss create mode 100644 app/admin/admin.scss create mode 100644 app/admin/editcat.js create mode 100644 app/admin/editcat.scss delete mode 100644 app/app.sass create mode 100644 app/app.scss create mode 100644 app/authentication.js create mode 100644 app/frontpage/frontpage.js create mode 100644 app/login/login.js create mode 100644 app/login/login.scss create mode 100644 app/login/logout.js create mode 100644 app/menu/menu.js create mode 100644 app/menu/menu.scss create mode 100644 app/widgets/common.scss create mode 100644 app/widgets/fileupload.js create mode 100644 nodemon.json create mode 100644 public/assets/img/favicon.png create mode 100644 public/assets/img/logo.png diff --git a/.gitignore b/.gitignore index ca23d9b..1827ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -60,5 +60,7 @@ typings/ # Local development config file config/config.json package-lock.json -public/* +public/assets/app.js +public/assets/app.css +public/assets/app.css.map diff --git a/api/authentication/google.mjs b/api/authentication/google.mjs new file mode 100644 index 0000000..aa7404d --- /dev/null +++ b/api/authentication/google.mjs @@ -0,0 +1,14 @@ +import google from 'googleapis' +import googleauth from 'google-auth-library' +import config from '../config' + +const oauth2Client = new googleauth.OAuth2Client(config.get('googleid')) + +// This is hard to have always running as it requires a +// test access token which always expire. + +/* istanbul ignore next */ +export function getProfile(token) { + return oauth2Client.getTokenInfo(token) +} + \ No newline at end of file diff --git a/api/authentication/routes.mjs b/api/authentication/routes.mjs new file mode 100644 index 0000000..41ea90f --- /dev/null +++ b/api/authentication/routes.mjs @@ -0,0 +1,39 @@ +import Staff from '../staff/model' +import Jwt from '../jwt' +import * as google from './google' + +export default class AuthRoutes { + constructor(opts = {}) { + Object.assign(this, { + Staff: opts.Staff || Staff, + jwt: opts.jwt || new Jwt(), + google: opts.google || google, + }) + } + + /* + * POST /api/login - Authenticate a user using social login + * + * @body {string} token - The google token to authenticate + * @returns + * + * { token: 'Authentication token' } + */ + async login(ctx) { + let output = await google.getProfile(ctx.request.body.token) + + if (output.email_verified !== 'true') ctx.throw(422, 'Email was not verified with google') + if (!output.email) ctx.throw(422, 'Email was missing from google response') + + let level = 1 + let staff = await this.Staff + .query({ where: { email: output.email }}) + .fetch({ require: false }) + + if (staff && staff.id && staff.get('level')) { + level = staff.get('level') + } + + ctx.body = { token: this.jwt.createToken(output.email, level) } + } +} diff --git a/api/jwt.mjs b/api/jwt.mjs index e59fcf9..2fc61fb 100644 --- a/api/jwt.mjs +++ b/api/jwt.mjs @@ -43,16 +43,11 @@ export default class Jwt { return this.jwt.decode(token) } - createStaffToken(staff, opts) { + createToken(email, level, opts) { return this.sign({ - id: staff.id, - level: staff.get('level'), - }, staff.get('password'), opts) - } - - async getUserSecret(header, payload) { - let staff = await this.Staff.getSingle(payload.id) - return staff.id + email: email, + level: level, + }, email, opts) } static jwtMiddleware() { diff --git a/api/media/model.mjs b/api/media/model.mjs new file mode 100644 index 0000000..bb22e79 --- /dev/null +++ b/api/media/model.mjs @@ -0,0 +1,56 @@ +import path from 'path' +import bookshelf from '../bookshelf' + +/* + +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 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')}` + }, + }, +}, { + baseUrl: 'https://cdn-nfp.global.ssl.fastly.net', + + getSubUrl(input, size) { + 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}.jpg` + }, +}) + +export default Media diff --git a/api/media/multer.mjs b/api/media/multer.mjs new file mode 100644 index 0000000..43fc168 --- /dev/null +++ b/api/media/multer.mjs @@ -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) + }) + }) +} diff --git a/api/media/resize.mjs b/api/media/resize.mjs new file mode 100644 index 0000000..2f3af0b --- /dev/null +++ b/api/media/resize.mjs @@ -0,0 +1,38 @@ +import sharp from 'sharp' +import Media from './model' + +export default class Resizer { + constructor(opts = {}) { + Object.assign(this, { + Media: opts.Media || Media, + sharp: opts.sharp || sharp, + }) + } + + createSmall(input) { + let output = this.Media.getSubUrl(input, 'small') + + return this.sharp(input) + .resize(300, 300) + .toFile(output) + .then(() => output) + } + + createMedium(input) { + let output = this.Media.getSubUrl(input, 'medium') + + return this.sharp(input) + .resize(700, 700) + .toFile(output) + .then(() => output) + } + + autoRotate(input) { + const output = `${input}_2.jpg` + + return this.sharp(input) + .rotate() + .toFile(output) + .then(() => output) + } +} diff --git a/api/media/routes.mjs b/api/media/routes.mjs new file mode 100644 index 0000000..b8602ee --- /dev/null +++ b/api/media/routes.mjs @@ -0,0 +1,43 @@ +import config from '../config' +import Media from './model' +import * as multer from './multer' +import Resizer from './resize' +import { uploadFile } from './upload' +import Jwt from '../jwt' + +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 smallPath = await this.resize.createSmall(result.path) + let mediumPath = await this.resize.createMedium(result.path) + + let token = this.jwt.signDirect({ site: config.get('upload:name') }, config.get('upload:secret')) + + let [large, small, medium] = await Promise.all([ + this.uploadFile(token, result.path), + this.uploadFile(token, smallPath), + this.uploadFile(token, mediumPath), + ]) + + ctx.body = await this.Media.create({ + filename: result.originalname, + filetype: result.mimetype, + small_image: small.path, + medium_image: medium.path, + large_image: large.path, + size: result.size, + staff_id: ctx.state.user.id, + }) + } +} diff --git a/api/media/upload.mjs b/api/media/upload.mjs new file mode 100644 index 0000000..9a257a0 --- /dev/null +++ b/api/media/upload.mjs @@ -0,0 +1,73 @@ +import http from 'http' +import path from 'path' +import fs from 'fs' + +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: 2111, + hostname: 'storage01.nfp.is', + 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) { + // Do nothing + } + resolve(output) + }) + }) + }) + }) +} + +export function overrideStub(newStub) { + stub = newStub +} diff --git a/api/router.mjs b/api/router.mjs index aa89c79..9f235c1 100644 --- a/api/router.mjs +++ b/api/router.mjs @@ -1,14 +1,19 @@ /* eslint max-len: 0 */ import Router from 'koa-router' -// import access from './access' -// import AuthRoutes from './authentication/routes' -// import { restrict } from './access/middleware' +import access from './access' +import AuthRoutes from './authentication/routes' +import MediaRoutes from './media/routes' +import { restrict } from './access/middleware' const router = new Router() // API Authentication -// const authentication = new AuthRoutes() -// router.post('/api/login', authentication.login.bind(authentication)) +const authentication = new AuthRoutes() +router.post('/api/login', authentication.login.bind(authentication)) + +// API Media +const media = new MediaRoutes() +router.post('/api/media', restrict(access.Manager), media.upload.bind(media)) export default router diff --git a/app/_common.scss b/app/_common.scss new file mode 100644 index 0000000..1b94358 --- /dev/null +++ b/app/_common.scss @@ -0,0 +1,17 @@ +$primary-bg: #01579b; +$primary-fg: white; +$primary-light-bg: #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; + +$border: #ccc; +$title-fg: #555; +$meta-fg: #999; \ No newline at end of file diff --git a/app/admin/admin.scss b/app/admin/admin.scss new file mode 100644 index 0000000..19a6e18 --- /dev/null +++ b/app/admin/admin.scss @@ -0,0 +1,41 @@ + +.admin-wrapper { + flex-grow: 2; + display: flex; + flex-direction: column; + background: $primary-bg; + padding: 20px; +} + +article.editcat { + text-align: center; + background: white; + padding: 0 0 20px; + + header { + padding: 10px; + background: $secondary-bg; + + h1 { + color: $secondary-fg; + } + } + + form { + align-items: center; + align-self: center; + padding: 20px 40px; + } + + h5 { + margin-bottom: 20px; + } + + .loading-spinner { + width: 240px; + height: 50px; + position: relative; + } +} + +@import 'editcat' diff --git a/app/admin/editcat.js b/app/admin/editcat.js new file mode 100644 index 0000000..85cd13b --- /dev/null +++ b/app/admin/editcat.js @@ -0,0 +1,27 @@ +const m = require('mithril') +const Authentication = require('../authentication') + +const EditCategory = { + loading: true, + + oninit: function(vnode) { + console.log(vnode.attrs) + EditCategory.loading = !!m.route.param('id') + }, + + view: function() { + return ( + EditCategory.loading ? + m('div.loading-spinner') + : m('div.admin-wrapper', + m('article.editcat', [ + m('header', m('h1', 'Edit category')), + m('form.editcat', [ + ]) + ]) + ) + ) + }, +} + +module.exports = EditCategory diff --git a/app/admin/editcat.scss b/app/admin/editcat.scss new file mode 100644 index 0000000..e69de29 diff --git a/app/app.sass b/app/app.sass deleted file mode 100644 index 98cebb5..0000000 --- a/app/app.sass +++ /dev/null @@ -1,4 +0,0 @@ - -body - margin: 0 - padding: 0 diff --git a/app/app.scss b/app/app.scss new file mode 100644 index 0000000..b4ba63e --- /dev/null +++ b/app/app.scss @@ -0,0 +1,112 @@ +@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 { + height: 100%; + 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; +} + +.container { + width: 100%; + height: 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; +} + +[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; + } + } +} + +@import 'menu/menu'; +@import 'login/login'; +@import 'admin/admin'; diff --git a/app/authentication.js b/app/authentication.js new file mode 100644 index 0000000..30f99e8 --- /dev/null +++ b/app/authentication.js @@ -0,0 +1,56 @@ +const m = require('mithril') +const jwt = require('jsonwebtoken') + +const storageName = 'logintoken' +const loadingListeners = [] + +window.googleLoaded = function() { + Authentication.loadedGoogle = true + while (Authentication.loadingListeners.length) { + Authentication.loadingListeners.pop()() + } +} + +const Authentication = { + currentUser: null, + loadedGoogle: false, + loadingGoogle: false, + loadingListeners: [], + + updateToken: function(token) { + if (!token) return Authentication.clearToken() + localStorage.setItem(storageName, token) + Authentication.currentUser = jwt.decode(token) + }, + + clearToken: function() { + Authentication.currentUser = null + localStorage.removeItem(storageName) + }, + + createGoogleScript: function() { + if (Authentication.loadedGoogle) return Promise.resolve() + return new Promise(function (res) { + if (Authentication.loadedGoogle) return res() + Authentication.loadingListeners.push(res) + + if (Authentication.loadingGoogle) return + Authentication.loadingGoogle = true + + let gscript = document.createElement('script') + gscript.type = 'text/javascript' + gscript.async = true + gscript.defer = true + gscript.src = 'https://apis.google.com/js/platform.js?onload=googleLoaded' + document.body.appendChild(gscript) + }) + } +} + +Authentication.updateToken(localStorage.getItem(storageName)) + +if (Authentication.currentUser) { + // Authentication.createGoogleScript() +} + +module.exports = Authentication diff --git a/app/frontpage/frontpage.js b/app/frontpage/frontpage.js new file mode 100644 index 0000000..6a3f49f --- /dev/null +++ b/app/frontpage/frontpage.js @@ -0,0 +1,12 @@ +const m = require('mithril') + +module.exports = { + view: function() { + return m('article', [ + m('header', [ + m('h1', 'Welcome to NFP Moe'), + m('span.meta', 'Last updated many years ago'), + ]), + ]) + } +} diff --git a/app/index.js b/app/index.js index a92ff52..8deae3f 100644 --- a/app/index.js +++ b/app/index.js @@ -1 +1,21 @@ -console.log('Success') \ No newline at end of file +const m = require('mithril') + +m.route.prefix('') + +const Authentication = require('./authentication') +const Menu = require('./menu/menu') +const Frontpage = require('./frontpage/frontpage') +const Login = require('./login/login') +const Logout = require('./login/logout') +const EditCategory = require('./admin/editcat') + +const menuRoot = document.getElementById('nav') +const mainRoot = document.getElementById('main') + +m.route(mainRoot, '/', { + '/': Frontpage, + '/login': Login, + '/logout': Logout, + '/admin/addcat': EditCategory, +}) +m.mount(menuRoot, Menu) diff --git a/app/login/login.js b/app/login/login.js new file mode 100644 index 0000000..eae01be --- /dev/null +++ b/app/login/login.js @@ -0,0 +1,83 @@ +const m = require('mithril') +const Authentication = require('../authentication') + +const Login = { + loadedGoogle: false, + loading: false, + error: '', + + initGoogleButton: function() { + gapi.signin2.render('googlesignin', { + 'scope': 'email', + 'width': 240, + 'height': 50, + 'longtitle': true, + 'theme': 'dark', + 'onsuccess': Login.onGoogleSuccess, + 'onfailure': Login.onGoogleFailure, + }); + }, + + onGoogleSuccess: function(googleUser) { + Login.loading = true + m.redraw() + + m.request({ + method: 'POST', + url: '/api/login', + data: { token: googleUser.Zi.access_token }, + }) + .then(function(result) { + Authentication.updateToken(result.token) + m.route.set('/') + }) + .catch(function(error) { + Login.error = 'Error while logging into NFP! ' + error.code + ': ' + error.response.message + let auth2 = gapi.auth2.getAuthInstance(); + return auth2.signOut() + }) + .then(function () { + Login.loading = false + m.redraw() + }) + }, + + onGoogleFailure: function(error) { + Login.error = 'Error while logging into Google: ' + error + m.redraw() + Authentication.createGoogleScript() + }, + + oninit: function() { + if (Authentication.currentUser) return m.route.set('/') + Login.error = '' + }, + + oncreate: function() { + if (Authentication.currentUser) return + Authentication.createGoogleScript() + .then(function() { + Login.initGoogleButton() + }) + }, + + view: function() { + return [ + m('div.login-wrapper', [ + m('article.login', [ + m('header', [ + m('h1', 'NFP.moe login'), + ]), + m('div.content', [ + m('h5', 'Please login using google to access restricted area'), + Login.error ? m('div.error', Login.error) : null, + Login.loading ? m('div.loading-spinner') : null, + m('div#googlesignin', { hidden: Login.loading }, m('div.loading-spinner')), + ]) + ]), + ]), + ] + } +} + +module.exports = Login diff --git a/app/login/login.scss b/app/login/login.scss new file mode 100644 index 0000000..58fa542 --- /dev/null +++ b/app/login/login.scss @@ -0,0 +1,33 @@ +@import '../_common'; + +.login-wrapper { + flex-grow: 2; + display: flex; + flex-direction: column; + justify-content: center; + background: $border; +} + +article.login { + text-align: center; + flex-grow: 0; + border: 1px solid $title-fg; + background: white; + 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; + } +} diff --git a/app/login/logout.js b/app/login/logout.js new file mode 100644 index 0000000..2f0fca3 --- /dev/null +++ b/app/login/logout.js @@ -0,0 +1,28 @@ +const m = require('mithril') +const Authentication = require('../authentication') + +const Logout = { + oninit: function() { + Authentication.createGoogleScript() + .then(function() { + return new Promise(function (res) { + gapi.load('auth2', res); + }) + }) + .then(function() { return gapi.auth2.init() }) + .then(function() { + let auth2 = gapi.auth2.getAuthInstance(); + return auth2.signOut() + }) + .then(function() { + Authentication.clearToken() + m.route.set('/') + }) + }, + + view: function() { + return m('div.loading-spinner') + }, +} + +module.exports = Logout diff --git a/app/menu/menu.js b/app/menu/menu.js new file mode 100644 index 0000000..682dd20 --- /dev/null +++ b/app/menu/menu.js @@ -0,0 +1,47 @@ +const m = require('mithril') +const Authentication = require('../authentication') + +const Menu = { + currentActive: 'home', + + onbeforeupdate: function() { + let currentPath = m.route.get() + if (currentPath === '/') Menu.currentActive = 'home' + else if (currentPath === '/login') Menu.currentActive = 'login' + else Menu.currentActive = 'none' + }, + + oninit: function() { + Menu.onbeforeupdate() + }, + + view: function() { + return [ + m('div.top', [ + m('h2', 'NFP Moe'), + m('aside', Authentication.currentUser ? [ + m('p', 'Welcome ' + Authentication.currentUser.email), + (Authentication.currentUser.level >= 100 ? + m('a[href=/admin/addcat]', { oncreate: m.route.link }, 'Create category') + : null + ), + m('a[href=/logout]', { oncreate: m.route.link }, 'Logout') + ] : [ + m('a[href=/login]', { oncreate: m.route.link }, 'Login') + ]) + ]), + m('nav', [ + m('a[href=/]', { + class: Menu.currentActive === 'home' ? 'active' : '', + oncreate: m.route.link + }, 'Home'), + m('a[href=/articles]', { + class: Menu.currentActive === 'articles' ? 'active' : '', + oncreate: m.route.link + }, 'Articles'), + ]), + ] + } +} + +module.exports = Menu diff --git a/app/menu/menu.scss b/app/menu/menu.scss new file mode 100644 index 0000000..19c0beb --- /dev/null +++ b/app/menu/menu.scss @@ -0,0 +1,65 @@ +@import '../_common'; + +#nav { + display: flex; + flex-direction: column; + + .top { + background: url('./img/logo.png') 25px center no-repeat $primary-dark-bg; + color: $primary-dark-fg; + padding: 0 10px 0 120px; + height: 100px; + display: flex; + + h2 { + flex-grow: 2; + align-self: center; + font-size: 30px; + } + + aside { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 0; + + p { + font-size: 0.8em; + color: $meta-fg; + padding-bottom: 5px; + } + + a, a:visited { + font-weight: bold; + text-align: center; + color: $secondary-light-bg; + font-size: 0.8em; + line-height: 1.4em; + text-decoration: none; + } + } + } + + nav { + display: flex; + background: $primary-light-bg; + color: $primary-light-fg; + + 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; + } + } + } +} diff --git a/app/widgets/common.scss b/app/widgets/common.scss new file mode 100644 index 0000000..e69de29 diff --git a/app/widgets/fileupload.js b/app/widgets/fileupload.js new file mode 100644 index 0000000..6e0e098 --- /dev/null +++ b/app/widgets/fileupload.js @@ -0,0 +1,26 @@ +const m = require('mithril') + +const Login = { + oninit: function(vnode) { + vnode.state.media = null + vnode.state.error = '' + }, + + view: function(vnode) { + let media = vnode.state.media + + return m('fileupload', [ + (media ? + m('a', { + href: media.large_url, + style: { + 'background-image': 'url(' + media.medium_url + ')', + } + }) : + m('div.empty') + ), + ]) + } +} + +module.exports = Login diff --git a/config/config.default.json b/config/config.default.json index 88e743d..251fa2a 100644 --- a/config/config.default.json +++ b/config/config.default.json @@ -32,6 +32,7 @@ "expiresIn": 604800 } }, + "googleid": "1076074914074-3no1difo1jq3dfug3glfb25pn1t8idud.apps.googleusercontent.com", "sessionsecret": "this-is-session-secret-lol", "bcrypt": 5, "fileSize": 524288000, diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..075795d --- /dev/null +++ b/nodemon.json @@ -0,0 +1,3 @@ +{ + "ignore": ["app/**", "public/**"] +} \ No newline at end of file diff --git a/package.json b/package.json index 8531fa6..8bfa019 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,11 @@ "scripts": { "lint": "eslint .", "start": "node --experimental-modules index.mjs", - "build": "sass app/app.sass public/assets/app.css && browserify -d app/index.js -o public/assets/app.js", + "build": "sass app/app.scss public/assets/app.css && browserify -d app/index.js -o public/assets/app.js", "test": "echo \"Error: no test specified\" && exit 1", "watch:api": "nodemon --experimental-modules index.mjs | bunyan", - "watch:app": "watchify app/index.js -o public/assets/app.js", - "watch:sass": "sass --watch app/app.sass public/assets/app.css", + "watch:app": "watchify -d app/index.js -o public/assets/app.js", + "watch:sass": "sass --watch app/app.scss public/assets/app.css", "dev": "run-p watch:api watch:app watch:sass", "prod": "npm run build && npm start" }, @@ -32,6 +32,7 @@ "bookshelf": "^0.14.2", "bunyan-lite": "^1.0.1", "format-link-header": "^2.1.0", + "googleapis": "^37.2.0", "http-errors": "^1.7.2", "jsonwebtoken": "^8.4.0", "knex": "^0.16.3", diff --git a/public/assets/img/favicon.png b/public/assets/img/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..667da1dde1633c9e990997aa36a5ca3d2ae3eb55 GIT binary patch literal 2515 zcmV;^2`u)BP)7zAQ0lj(cma_WKoQ1PU&KIvy0tibyZi@ zy;b*q!^wj0>RCRk=f#iy`Um;662P`H-m&$tzR`QSKLBP1IXE1jSnF9k2VejMKp}_( zdFL6Jokyb0%~yob&YAr|e{FT8zugA_0CdiEmscrkW{yo~so8ARNup;KB!wU;r4(YC zPlG^4VW71}LeC(Sbk6EPrD-g+s%MQTk@ZGPN?F-C616++tdWJGPSSWbIil_Dy&%*| zi7*PnC_uuhs**UW*K2?*r5BQv7D`JY1Ry9Wt+PTP5e0#2wwI(%qqG62yrYLelX05Wsuyy!X~xW1MHKDq9*` z%*%SCRaMqICX#8bu7t{qxpllKZKJc2wR>T`fhrJzj?yHG<4(6{tZ@#Gk7wiY41lQB zXaVpGedpL1OC&slF*b}+5P>m%Ub#lMhcdJtRgjJ+d6IQ|s~b_X!!iV^P)VV+XNcp3 znVt1TzL*ya%SaN0bC%gV?-{@|2q^)j2N7qjd^TqYRFpM(%c9Y$9Gp6LKB~1}y>PkK zS*9>ysaZ-CLPSAc%#ndeSkJ(?C@bSwdSdI52-q_rRnDUn*3Mj*FlCCsI$6ORR#b6JqavJ^W<#6<|I3=p2*lg(z4u@JXAcKQ#&}Wh#@!Rn0$slPrVx@509Z*W z5YbgirlrHdD2 zJ!NLKwjtUp&VbYeh=S021S#d}`s#Fc3?8Sma%1yllOG2nEa!QU1aVZ8;1CeNG6R5| z&8owl$90Wfs@LEB#)UVp{a?Qy%=hQs28~XZM2CZIdHDE|XJ$r$l8I_roFqv;8I`EM z^+$jB+mAj@kb+)w^QBWtN+d#NZ!Itbdt!I`oa z5_>4+WOM{tWwko92LaAk&E-=E!+{F*jcZ@2*OfE&^0h0eCXfgyJTqHl>j1djNWS~M zzx>@Nza}C7_~s4C#q`Ob81$WotkqT4%2rP@xV^pMQoAmc0P-S=mbWhU_nt1d1k4Yw zY%Zy!Az}?|5eSi(2&96t^4<$&u3mfn{7b8{DodWlPHcbnal#IRI7!n$$;OFI3C;*9 zin*-^B+<>M(+}SNkDGt`XFT4C!T?yH)+B^LLgk=x9OeBg82QPd@w8QUw!}2u?E6>)C!`Il|mp7;JlYo zL88EUu$H7jt%5jYkazDra!8D%1Zjk%-da0zUOFH4@9ykGqqFBaz*=dM5)FR-tG|7; zeg3^SufBct#Mu)l&;!kja{IyEqrq;{PCIKoAr)I%Sw6}22I~ACrM=FjwXYD`tYZ}_~h$9`rgGW7e=GK$zT`DNgA0f zcAa#AVp7bDFYoL&TW9v~KDqtLzo*BCr13>K7nMZ>K$NEP3S=Ps@wiOVFbG(Zr zF7tVjKQD6^Ll(zuDkwqFK2_X zEUK*8TDx{lb<#6iXTY*Uiw%&PXb@Qxak#%}N9EC~&fOYH{JxK3O=~bF>+-z-}ZuizvilSV! zSJwj_j1TtETSg=gpqtSUFlQb-o*Yk&3Id1hSUSsDr(0h+F=wG`ZPFnMK=LRof{}oz zqP@dOck|88>Pz`>7-*T7w)e_wB(-_AXUZusknqTYFbE6-x!_TM*C~EpIFI_1#&g`7N3VVS>-`Tu z`108_-FWpa5D^KDjwP}{OuDP>Gp_}Lp5FUk=L`V^-b)5=7gi!KMmc(NnzEIRTC-UT z<7eBCfAi5t_kVM1u=iYcxBU8r_SUs0yL0o*lT~Y*XQQMJ%twd&j6mRpPFA-rG05Wa z?~3suAqdB!Dy;&S#gqs+8Xc!=OUY6yGY!{QsSz7-w>TziR*h002ovPDHLkV1l;W)3X2o literal 0 HcmV?d00001 diff --git a/public/assets/img/logo.png b/public/assets/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..48ed4d8ca206753cacb1e7105dce3e23af88b188 GIT binary patch literal 11118 zcmV-!E0NTRP)ag<1!q)5qBB-^qS zPcdwulQbOLiQRC50C|ZMAV32~UK;46fzCtFd1=RKce>r4;&w~6+nQ`i7A4A}28wf4 zvBo>p9nU?(p4M7l9`>n{WX%+do$SKFy>+nf-h2J`x4!ia|Nm0Fqi8$_Mc`|r4(pZWn!CNv_O=}lrNTRw9VZ7Br-v1} z3M4k5bwD8~5sa~D1quOiXxT&1G@|){IA%WL+J3@|Tb2tG&EL+{|Nj7ax+lE*c*8wQ z1+giJRUjf@5n^kJIw@HvC(BauEG3C!tTku^B?0k5S=CG@GrSKxxaN80%#8g)cw?OM z(Tg#Ee&elqR&~3l%r70U`H#*_$aRImfeIv%rr+x_91h6xoG6VkR)Yv4g3$_VGztZ} z{oJ$ztrQXhuJM$W<8WNEJA*I1;rZ8J6`r5|e-x1W65;QiopN?@Or#xJ1(XsN7JDo% zEs*6o#zttZ0iiBSM1(YniL52E28{xA3krY|P#{Y1L72@d#(Rgv*3$2F*)QM|uQ>kK zPZwNo-rTSZYft>vTMfp0`hkD&*pyTG7%L5^KomzDKXr^FM^;F)6m1L`g%5%8XvAbZ zp{X0aDfE5i=wPIP$sOv-Gp;pI{I4-d$@ zyMh1wz5^EH8H5_|1O0^|r_LN_FzBL;LTOM+aE;?&|9~V(G1j8cc<(VNk|ZI709N5~ zD6K)!P9srh0jmU~HBp|kGb%`nsle3~Rl(6a zPH_ChQH&0V7C-<+@WS5y0m}=E#8Gt1M<4;M6(9r&Xi=zk&1u0qalu{_ANUh=c z+9FyMC_&@t$M8S=>;h|peg5HRtMB{y^;@}I{pPtTi}?&igL4&YC)PM|>KMwlcM9SW zAc(MgFk*3W2^&YP#c0r4VXVPg3ko7@+bN<9N&sp7g3_R@MjMUR3MB!n1ZzNPf%)~i zvG8C0*nr>tg&_;(Z3g5+%Z~e&XISwN9KC**6DQYD%7Y3B0!kqQ)9H*nOR-kZEkp3Z zBNFESyZLDW5czHnQYecOL@}RJpag{nrHGB?{SWr}M;{upXx=73PFvw8&Wwpwg%XD~ ziW4V~V6g|ZeZ^U3#KBt_MHwFhh*Kfeb4jPt(3xfPg$09 zI~|NMtqq9ay~l@um-)#eXr%~%1drD3V4J30h14xi1WFn9MpKR)IffUHa}~xYw4Pg- z5=?MpI`A7GTx5N+$M2u7-Z~3O6#Qp*S^n%-2mH!2Irl6TbWMSXptT}OB6`CMk7 zs;rnz3;F{?;{#RIP?R-s9HF#9#Ie0|NL6}v_ou9Hth2FkK-~z%tiT#WQ52vI)$+xv_^8$zgB zM=g*JmdNs)pMP5O(Tf{gtL6K5s*m3p6HH1I40Tm;u)l{2fu=4oMhKyy)6Ezx^bi7Z z8k2W2tTnfSEKtOe#Z@&3c<(_JK|nAB=h`?$Xq}}5L=;NtHr~?Uyu(X$o9uKnc^LWN~GQEKf1k5GM(!DgAy9K`0~3=x{>Q1W=l$cK8r5M$g}arY;Jy zJjc13G>$+i8rPoZeQN>6T9_}vS(c);rqUHn(=Z$^5G95r&T!6BS0m7#vh;M)l)=IP z=R6^JirEZfEmc)g&Bk~W@z4Ka${V}x2U~V00m>>+6;pyVtvO>T%Z3YYTxUEf8I7jw z9~=_LmMFGFks-|z@;oJoM;Sv|Rz%j2cXGPj4mL8($_brrj1Y*Dh&YLelNf6)0%)s< zZAzCM-ojys@MKCC3Xc|vhRT!fgPsXH4M4VdkPD+v`G|dcC z73_~{p55_3cpP%1;|b2+iYyV04~Fd<`%DiD_HT@M5iYK5^HV-4OrjEyLZ z600>y9AT{?gg{l6vw)gr&;y5EpdziEf zCIS^9-r{}DWIDx*Fgh4fR}DrRtkoo0Ozj)2RwPEfxmD)$u*SD}_sxKr;33Ttn!14i z_0*FkSsQtWfKsgktgD)C7mDeOJdY3wgrIPZM|?m7jc*`03>u{@HnNmO0l_0)(bP@5 zS~Lw>8MLwtdW$4^7n63mu`^b-#ErlK;fm=b7cY!2sJ-PD%tqoS|HsA{FoR=WXW(7tYf)6Yv_C~2e6 zx*>_$sb-93acQZ|d?gUP0}+A`ZRA@ydi@2GyhGmWlXf~RA3e&c(`V=phA3rl&Qn)a z8x0Fg$5WhZ;~oj^bIi(u`;NLd2gtv^l<;RSCzOr4m1YO;iQ|}AQLw$ejSp^KBo1>c zX!Df1Zsvs?O$Y(!JWbtDmNg3tONfAX{#FkgS;try$OV%HPjlozj79Cr(H3-nSz}m6nc<%@Syc3F9Nl{cd7l@)5 zr45b$A(A@>tWR?sAWEaOL7`D1bg~?yZ@Qy-g60DfXb0sy+E`F(ZaD$8CCxg_W))gl zswNPnUHS_PD4XB{xBzj|p{O0s!>K#&BJcI-EUZA3w2o9;FdAhI+E|KtlGyEZXzB*% zd^>F>1!Ym;gV5-d^=gTY(GQv2ePui6Y=1-&L7Jrm=Rh2>j?n@k_*;dY@C^kXtu?Pwaufe-xhA5H-C!=?Wp*|of|zl=8>Cgvy` zO%JazdyZ#2$mYO9&m72SM*5ra`0=L25+yi6^) zs>U_$t*e;*$)$`q@AKvBj^iC+?U-gZD{%n=8YOC;%d~ln)`oy0XhR6X`t~6gHxGIJ z$|k!9yST>D&pRAhUE)W(3!HwWM`#?2M^AG4;dgLtV}q0ToTGna4P#?424xIrg;$zs zQPBipJe_j!;$^OGACMIzXQ5Wh8mTx#GSf2FN|{(|zvI$`Ff zG$Qx_R;jtu6|{yViI~-%R9h}wyvq94CN^DQX*gu}a7>hT5gT!SEAN#6o{aKZ$vDZIhq< zv3HaA7xCc@1dZ1U!4q6SOt?Ko=DmqvB__=}=s2OS>v<$8K-*tQ;)LzJ5tlb^aQ*6a zjviUz-m@nuiVBRuL=l2P6EWy7aj-uj&w8vKJBc+hi;F7^7nUH7AhHl!B#J1V@VkHT z5q{@){tcI3y+$V)kasd3dE#-(bii-_KYz*Yw8l9>#L(CdI$vxf?x1fEAYU#!cpsB= zy4>}^!+7U!wF24~sfldF{@wviT`^o9aQ9uu30m0P*&;|o<7+Bc()gM1o-s)zf8v`a~Z?Z*IPB2;sz9a+(c$&Iq zq1)lq=~Lvx9IG{r_pDEZZ7k0O!??1yua#L7ND@moi5WHFR{KDymbwrp5qF-sgVA^w zgT^_bt~|YkMU<*BSR$Jcr7>T-^cqjR_el~9MgnH;2wTuXTU!wWCg$Sh>qt2vM!+ge zp7ZFtp5oz$9w9M?JIX5huQ4|B%r9L6Z~v~3xOblRxQOr zlqQKHlEo#OdWHpxS;O+`2_EY#bN$L2q_e86ZXHf&oFlOjvEml12z7HAzuAmY5QOP? z%t|*Sw-&E0rrYKC$y5BuQ%|$Ax5LWuCC)$h8Lakf7YQ%b34Ki)n0dL4fHVOsaIm{g z6h&=?O#u`!e2InrOI(@6-DGYb=TE?-481yyA!QDh<%Rn4^a z-!>@wp#iZ71Tadozq><}#L!JqdLEb=u!&|enJ_rA%Kn9Yrn5u3v2gCx5t0x67%#s5 zI-Mxy(Wjr}*s&uhBlFI;n)h}E@v6O^Z~L{1ns+_mA?= zda>rUmtH}p`)H+b^TE$Vv0eB1(<{;Kv5*MR1dIZ$gsrVz@+@Z1j}X)5GCI~o@qo2^ z9$Wm2@MLYg(zyR zBzRPy&6BJ~N0vw_l(G~}$;SFRnbORhqu*^Q8E*@vDv1 zXe=5-M4qK|z2WM=e~y2)bHGpk($8`FuH!5Z2c$`giUh1hv_UXPfW}pf_V<}h+IFTy z22qAimNCe4vN%OWF(ywzDQr-96|w&6EBM)rU=&sD(FU}*w+E16YA{h-wB6pEaOKra z8gJR&opEN+BRGplO4g0YQ%esEuU+E9|MNd3J$Zz)Kk^Qq`Kb?Z@~)FDsYWT9s$smh z&(_sT9PF-BOhzo7SmETIr%5_7bu(e-#uV=pPMo?Il?~Cx<15YOi|6^^Yq%8uAP#=_R|Mm0y z;j35p$$#+ox&PsFH1(9ND_5uvcQL+TX(473<>ZMbRs}HyHmZn*j=lXYzWUWePTp~j z*^PpizVH<;eC4wY-3%KmngG^leDFm2Z35)Xs=_yA+ZZYuED4Dcwy*8rn{y~DC<1A$ zh$Rq-r)lbzY2?AuP>weFSO4^%^Q*u4s~A&Y{RAevcswRg$vP?H{Sml=#nmo(&k$*0 zahP#qYlDyc?ibm9`3BSdO^zSyVyq%)Ap}okEk1;KoV1-|gfj}p5vGTOmRCaBtxW*J9M zo@DLVN$Qz!<+W`tytK*Y^+UufPT#RW?8mrbjP*6qoT}*oFM%kI2_c}gy8RaN@W9j0 zKvhEC$>|UJ9Bl71$ReC5u3x=Df8`8;k~oeioWm)@VOcSnIZ7wgIwojXI=RCAcb#GX zjf>FCFhL`Em%W`GvOz{~kkad(!YIwGm~#2TdG^9xelBW^l8zZi~e3|WQ zJ1DOi_J`Et5;tj>Y|T)!8t*)J+T&u+6cxKC? z+%5|_5y9yrmPkXMXNY3X&Itq*C{1c(HZN?UUCUI@7yE6iLP6~tj=tj|9(vEa>77`m zx4eRD6d`zwf4^Ty3f;v;j@@w=SJyWgEG}X5E;_Q9$YKnrn++!24n_+^ z7?LQ%n}oe_fzB2<`_K~%mXG3HKpTZ9*q;gO%}IhpWLZW(xlMrFk-$SIz-R%h35~FI zeH&#IQ75K}Ep=?Ef`Fu4y1YX`VUvhDQurod(+=C$Ht+!ki_6^e$m49!93k$a(nW|; z&=wUblo6Enplg&kv;>TanAXD1UdhUtM_4&=559IBY+fgi4T55O?D@x^^As*3iDMo+ zIlDc8yyt|l+-Vh)UO_Q!7;n$efG9=K79vaI0+xj9mo_Mhg1DD54mFkY=#?eb_jcLc z+QDPU2FpD3)Q^*|JjnhmMWSVNxTH9dxKu#jGe;d+>f9e(GIZzPirNjRR`0h_XI+-}e~74%shab`IgN$f#w7 zhE+zBJ{Mj;e*K6|w-C#(%T=Z0^w zg*>we_npu!`GtTH*txOSQX?criAAZSPoL-7i@S_AOL_|@ICkfK z43^i3vka{*zBs@rg%ZKeNA{@$&6WKWEAakvtq*+50Qsr=VVFUnEh76Su(`h5Hjju2 z(l(;2vZUoIdQz*I9!?1X@?MuBvJ6*N>2z`;6#X=3va`dbS6=0>Kk^YSe)+46ws*+0 zMV619!gq4aV1dE0Q^a*dLImT6PTr-{8=#ay1LbtWY%(DzTJ-~MhFc4~u$2(doLlug z(pPT{An#j(M^3p`I~P2`dnTijvhW~g&SEwgWvPpjSOv03SnTK2#f+k?87vK%X+?Ko zh>7C4_C+v8GuhkW#m|18gKO8ZN@ILON<%rFv3czV)A6h&{I{H6y2CC}91}&B>3B>v zn&E2UmL}^PqV-R%8!ql7=&ImDcgtG?$d8?M3=@Y)ON12<`+F0_$0%%DkP!%tplVN| z1B?Bb%z&?j$-zFo{(!^DA(N^ArAP-obR1)irPt|D>>bkSFLV0reVo4cZif91emutS zjaXYg0&`N|WIE&W`HPg337eZ6?C$JP9PD$ry@$2-R-yjpaiN9}zm_m91CO1md2;YW zR^8vX1P`66TT)#xAOS?Nzc)ikijr0xr4X8`A<>4#UPdR@luET)m%~GbOG6OHjm=HO zHFSD8N-tC1*yhqp=Lvfg;@*HW4?e`&U3X!lxK(7GgU3yP^j zk)T=$16B!faP+enqh!9r1L(*QX9*A(_B&kun=kN@-oN1NJ@;|#!fU+z=})m-1qLh2 zOs`($+J)DNyFIK`)a8Wf;fQN5yo#@jmK7ixD~byr`z)8f`~vmijAJUr?TwjEOQQVi zdj)*sF%|gm%L!+Xmi){+5}v!FcE#_R{K?0;6n&gdsyWf^kq;A$77q7!`J?~spK#`} zN3lu7#pgdmj>AM<7CQ;4&G2O)j0;>)H2YJG4y2KwRl?@Z9>S8MH(SD= z?`ZPBQT)dHJ-_j8!-c;TcK!D`Wq$cw#OalYBu!|fY`Lf2;~UTZjWJsETnx}I#W+W5 zHKx^5&LyLPezyl9P}egS7yDQVrZQ%&jkK<@*Sqk(;d&bg&p+bDpHQozD6W!^>ab7e=xyogI71fs8Mx6q(IL( zne#q99znOOG9rN}*0j>yK;1M*P$<#VS~E@z`Tcit^6|&W7Ka!ewK0xFc=Tqf8vp|1I=`T4^`WL4jNYpJ0nN7xJonUl1W_<1&@}{QoGx4aRkJI zGA+klBw!;$-i-;qp%_n@j;4&q1v7!}+0!hXIYk+2c6WEE>Q)NogQq?`Wc%VZHoo>6 z@x~s@PO+jaD>kC56@kzen8dJkX%jcAxqnTQeIMo2KfW%!zAc=~)wfQW4-975YQpc` zSm7zZ#Uqc_)S+&>?M_h^HLoA8us&MgUFkM`sh}!Ij6&U1WQ!uG);Ex}yEsfI6yc24bHXcKwM3kZM}ed`8?IRllb);EIoKXX|IPehQlk@x%}DZ7=PkfR?$Qz z!Yf1N8ye*(oWm=P)tYL*B2+b}7Zi6TiWkc7jmn2N!ngX6(^j~rx5LgP=M|^mN>6L*_H=<_D&>H%=<-}^2ekTSMnAL_T3dA%lHVuQS zW_y2(4wl8$E^%g2N^@nwG1?qaS2dyyS`0n}lnDN?DIlbW0p>Vfy#5QTAr8na-X`N~5YnJm9 zT)BLMd+t4nh(n1di-oB!ICf--EK&5bfHe-UHHl3K&f&aZlt#q{d-@1!xWs|;bdM}@ z^qvPexpJI~ubk)kPkbD`H$rxH@lDgR7e)B^bBE-sU4HS71%75_8@;#2-xTKi2jq#R z0$Yx`GFxVEo(C8$sGxXxJ?D*4!p}W0MMN=_U0@llpcfmI1pIsh3kj{j$f-ar9up$2 zU%bY>_nzX`WKx39Qq-_ZFb%a8>L_A_!LA&EewTW5K(x5X#9B5sZ*b>hkJ34^#?e!! z$d9Zdam?VJyQw0@=l|vJV;e_2_hF)h9LFcN1CRE*oHPe~U~!A12VFij>u^KNcL(JD zC~!h=)0Cc<=kqP1Z8{+e*TD0eiua#2D6Qy5iud(A_Z0`Ex&@$_N2SVXfNRFSvEi4i(^tt2oY4XJ(ln(ps=lQ}Hzr+Xs z-aARwj-b*Mv>{#|^5}b?=Cx-(&-TU^(R{vo1E<^#>Zs5E{f6P!RulSEJd*6O&>8XB z@o`=(^mnd@ykk&dihU+NWj(Y;^FUG&=>mMaEyWjSib>g)1an*S9`!1BnGx0tE(l&| zfBIHM)dY`t(UX#830(GykL;{4 z3o)5ZS+NoIcd-y1DLC0H39e?#b+{DTll@$O->M_qlfQT^YeVd&rdQ7BHXgj-LMvOn zxp7Jp1Q(!jn$i9YO@!91#7tECz>7y$Nd^NXy1DV@pS=#31=W5G~(;>k` zbb@dq6yFgLbMGBb{MOH&8*{k3&kM5?TyU*7zTpi&eX5~n9h(!&CvGHMJ~TYhAEOQ@ zq)t$w1+~^E+zOtZ!#56@&X|lx z#94L&&@KW(C z)uiIcou^QFjFP~!mv;GIKE2JZ7x>tR`Pq9u%Rl%8qdENXFOd+ysJOrJ!?~XRi8h&R(~@fcPx6+L@{w4F8gm@t6$&Axoa?`lYafX z8;8P4uUlE_&24i89116j+6ko(c+|W-p%EQ0F`xwB2q7p)EnX`&4ojZ9a>(;HN^Ug5 zogKrE-`nSFU*6{D^C8bH52z>*l0`A;%wnfacm|rdJNp zK{y(AS(4-I&M8$M_;hV~d2f-Yn}U5+d<*B@#unnp;<4=ioz&)Ey%F=su?`*GaMZwt z2VDyWh4v7F#tYOzD1pYcz9UF0V@2A^m5vm~3d(EBiDQ2%cyWCB;x-?-QnK|8gS>X= z`P3I$y74baMR1NLIGVa(HvZ;NR_)y!Krf3g;3}d2kh1-{1O}rr?O$rLQ9n z=SgfM@Z9HK$8TTe_~{e4{T=d5fojzbRw?dl_IW)%@@?-?ew$K>t(^n$tSn`){;enc z+ii_}E$5wgM*KHf$?~O5>e5lxj+xMw6h-5;X_L8|`bVV@)dos=CqtZO^V$KU-J0_^ zdc5{Mn3AtlhL^tdC628wGF&-EG1_4z4s}Vpw6EEiC9MA)Qu5RZyO%b|k1x?#?GTa} zvyxFyrg$-!Ec(GDFQ48>`0=wb!-3-6{lMQGe)|aS`9t9ahV&TF%{^zE0}>jJHCqgs zp+rz~B^5H?>Q6i#$8=KgrRQFv+^_MX8LIL3^>Ejw33p94QOy{iEb-;_ZylULp+d^& z>H$d}qjiMoTXq)~_-J`QyUP4PfS7}oCx2_L+h?I8^m5B**41~BlR<%Eq%@aI&Uw@0 zLcEHNd&IFvT*XbEy^^-{9D=7TD_j`}L^M&#R(^&Hy7N6BuB}=DE6j%3Ses?|p z)&Xe6YMOH;9O3#nrQh`!?HF}W@Uep~6+hS}))wHOea&;%`Go)SNz0>s&+~`hzk1*h z#mCeFeKq7{UU95GpyOwx2wAmG@Io1%V5SU*rq5P9SshS}O#DM#Lx9)HyIJbK z#2DDiY{yQN$MznTkiM&0?#-U)Aec3wEzGB07*qoM6N<$g2GB1 A4*&oF literal 0 HcmV?d00001 diff --git a/public/index.html b/public/index.html index cf1254d..1ab927f 100644 --- a/public/index.html +++ b/public/index.html @@ -1,6 +1,19 @@ - + + + + NFP Moe + + + + + + - Works +
+ +
+
+ - + \ No newline at end of file