Compare commits

..

3 commits

Author SHA1 Message Date
fad7acd5f7 nfp_moe: Fix typo in package.json
All checks were successful
/ deploy (push) Successful in -53h46m37s
2023-11-20 07:12:40 +00:00
2d7101d666 filadelfia_web: More development 2023-11-20 07:12:08 +00:00
16b87aabcf base: Slight changes 2023-11-20 07:11:57 +00:00
29 changed files with 872 additions and 157 deletions

View file

@ -77,6 +77,7 @@ export default class ArticleRoutes {
params = params.concat(mediaToDatabase(banner, body.remove_banner === 'true')) params = params.concat(mediaToDatabase(banner, body.remove_banner === 'true'))
params = params.concat(mediaToDatabase(media, body.remove_media === 'true')) params = params.concat(mediaToDatabase(media, body.remove_media === 'true'))
} }
let res = await ctx.db.safeCallProc('article_auth_get_update_create', params) let res = await ctx.db.safeCallProc('article_auth_get_update_create', params)
ctx.body = this.private_getUpdateArticle_resOutput(res) ctx.body = this.private_getUpdateArticle_resOutput(res)

View file

@ -53,6 +53,7 @@ nconf.defaults({
"iss": "dev", "iss": "dev",
"path": "https://media.nfp.is/media/resize", "path": "https://media.nfp.is/media/resize",
"filePath": "https://media.nfp.is/media", "filePath": "https://media.nfp.is/media",
"directFilePath": "https://media.nfp.is/media/noprefix",
"removePath": "https://media.nfp.is/media/", "removePath": "https://media.nfp.is/media/",
"preview": { "preview": {
"out": "base64", "out": "base64",

View file

@ -15,8 +15,6 @@ export function uploadMedia(file) {
} }
} }
console.log(media)
let body = {} let body = {}
if (media.preview) { if (media.preview) {

View file

@ -5,9 +5,9 @@ export function mediaToDatabase(media, removeFlag) {
media.type, media.type,
media.path, media.path,
media.size, media.size,
media.preview.base64, media.preview?.base64 || null,
media.sizes.small.avif.path.replace(/_small\.avif$/, ''), media.sizes?.small?.avif?.path?.replace(/_small\.avif$/, '') || null,
JSON.stringify(media.sizes), JSON.stringify(media.sizes || {}),
removeFlag ? 1 : 0, removeFlag ? 1 : 0,
] ]
} else { } else {

View file

@ -0,0 +1,49 @@
import config from '../../base/config.mjs'
import Client from '../../base/media/client.mjs'
import OriginalArticleRoutes from '../../base/article/routes.mjs'
import { deleteFile, uploadFile } from '../../base/media/upload.mjs'
import { parseVideos, parseVideo } from './util.mjs'
export default class ArticleRoutes extends OriginalArticleRoutes {
constructor(opts = {}) {
opts.requireAuth = true
super(opts)
Object.assign(this, {
uploadFile: uploadFile,
deleteFile: deleteFile,
})
}
register(server) {
server.flaska.get('/api/auth/articles', server.authenticate(), this.getVideos.bind(this))
server.flaska.put('/api/auth/articles/:id', [server.authenticate(), server.jsonHandler()], this.updateCreateArticle.bind(this))
server.flaska.get('/api/auth/articles/:id', server.authenticate(), this.auth_getSingleArticle.bind(this))
server.flaska.get('/api/auth/uploadToken', server.authenticate(), this.getUploadToken.bind(this))
server.flaska.delete('/api/auth/articles/:id', server.authenticate(), this.auth_removeSingleArticle.bind(this))
}
/** GET: /api/auth/articles */
async getVideos(ctx) {
let res = await ctx.db.safeCallProc('filadelfia_archive.article_auth_get_all', [ctx.state.auth_token])
ctx.body = {
videos: parseVideos(res.results[0]),
}
}
/** GET: /api/auth/uploadToken */
async getUploadToken(ctx) {
const media = config.get('media')
ctx.body = {
token: Client.createJwt({ iss: media.iss }, media.secret),
path: config.get('media:directFilePath'),
}
}
/** PUT: /api/auth/articles/:id */
async updateCreateArticle(ctx) {
return this.private_getUpdateArticle(ctx, ctx.req.body, null, ctx.req.body.media)
}
}

View file

@ -12,5 +12,8 @@ export function parseVideo(video) {
return null return null
} }
parseMediaAndBanner(video) parseMediaAndBanner(video)
video.metadata = JSON.parse(video.metadata || '{}')
delete video.banner_alt_prefix
delete video.media_alt_prefix
return video return video
} }

View file

@ -3,19 +3,19 @@ import Parent from '../base/server.mjs'
import StaticRoutes from '../base/static_routes.mjs' import StaticRoutes from '../base/static_routes.mjs'
import ServeHandler from './serve.mjs' import ServeHandler from './serve.mjs'
import AuthenticationRoutes from '../base/authentication/routes.mjs' import AuthenticationRoutes from '../base/authentication/routes.mjs'
import VideoRoutes from './video/routes.mjs' import ArticleRoutes from './article/routes.mjs'
export default class Server extends Parent { export default class Server extends Parent {
init() { init() {
super.init() super.init()
let localUtil = new this.core.sc.Util(import.meta.url) let localUtil = new this.core.sc.Util(import.meta.url)
delete this.flaskaOptions.appendHeaders['Content-Security-Policy'] this.flaskaOptions.appendHeaders['Content-Security-Policy'] = `default-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data: blob:; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; connect-src 'self' https://media.nfp.is/; media-src 'self' https://cdn.nfp.is/`,
this.flaskaOptions.nonce = [] this.flaskaOptions.nonce = []
this.routes = { this.routes = {
static: new StaticRoutes(), static: new StaticRoutes(),
auth: new AuthenticationRoutes(), auth: new AuthenticationRoutes(),
videos: new VideoRoutes(), article: new ArticleRoutes(),
} }
this.routes.serve = new ServeHandler({ this.routes.serve = new ServeHandler({
root: localUtil.getPathFromRoot('../public'), root: localUtil.getPathFromRoot('../public'),

View file

@ -1,36 +0,0 @@
import config from '../../base/config.mjs'
import Client from '../../base/media/client.mjs'
import { deleteFile, uploadFile } from '../../base/media/upload.mjs'
import { parseVideos, parseVideo } from './util.mjs'
export default class VideoRoutes {
constructor(opts = {}) {
Object.assign(this, {
uploadFile: uploadFile,
deleteFile: deleteFile,
})
}
register(server) {
server.flaska.get('/api/videos', server.authenticate(), this.getVideos.bind(this))
server.flaska.get('/api/videos/uploadToken', server.authenticate(), this.getUploadToken.bind(this))
}
/** GET: /api/videos */
async getVideos(ctx) {
console.log(ctx.state.auth_token)
let res = await ctx.db.safeCallProc('filadelfia_archive.videos_auth_get_all', [ctx.state.auth_token])
ctx.body = {
videos: parseVideos(res.results[0]),
}
}
async getUploadToken(ctx) {
const media = config.get('media')
return {
token: Client.createJwt({ iss: media.iss }, media.secret),
}
}
}

View file

@ -1,4 +1,25 @@
const Authentication = require('./authentication') const Authentication = require('./authentication')
const lang = require('./lang')
function safeParseReponse(str, status, url) {
if (status === 0) {
return new Error(lang.api_down)
}
if (str.slice(0, 9) === '<!doctype') {
if (status === 500) {
return new Error('Server is temporarily down, try again later.')
}
return new Error('Expected JSON but got HTML (' + status + ': ' + url.split('?')[0] + ')')
}
if (!str) {
return {}
}
try {
return JSON.parse(str)
} catch (err) {
return new Error('Unexpected non-JSON response: ' + err.message)
}
}
const api = { const api = {
loading: false, loading: false,
@ -13,31 +34,10 @@ const api = {
} }
options.extract = function(xhr) { options.extract = function(xhr) {
if (xhr.responseText && xhr.responseText.slice(0, 9) === '<!doctype') { let out = safeParseReponse(xhr.responseText, xhr.status, this.url)
if (xhr.status === 500) {
throw new Error('Server is temporarily down, try again later.')
}
throw new Error('Expected JSON but got HTML (' + xhr.status + ': ' + this.url.split('?')[0] + ')')
}
let out = null
if (pagination && xhr.status < 300) {
let headers = {}
xhr.getAllResponseHeaders().split('\r\n').forEach(function(item) { if (out instanceof Error) {
var splitted = item.split(': ') throw out
headers[splitted[0]] = splitted[1]
})
out = {
headers: headers || {},
data: JSON.parse(xhr.responseText),
}
} else {
if (xhr.responseText) {
out = JSON.parse(xhr.responseText)
} else {
out = {}
}
} }
if (xhr.status >= 300) { if (xhr.status >= 300) {
if (out.message) { if (out.message) {
@ -46,6 +46,19 @@ const api = {
console.error('Got error ' + xhr.status + ' but no error message:', out) console.error('Got error ' + xhr.status + ' but no error message:', out)
throw new Error('Unknown or empty response from server.') throw new Error('Unknown or empty response from server.')
} }
if (pagination) {
let headers = {}
xhr.getAllResponseHeaders().split('\r\n').forEach(function(item) {
let splitted = item.split(': ')
headers[splitted[0]] = splitted[1]
})
out = {
headers: headers || {},
data: out,
}
}
return out return out
} }
@ -67,7 +80,84 @@ const api = {
} }
return Promise.reject(error) return Promise.reject(error)
}) })
},
prettyFormatBytes: function(bytes) {
if (bytes < 1024) {
return `${bytes} B`
} }
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
},
uploadFileProgress: function(options, file, reporter) {
api.loading = true
return new Promise(function(res, rej) {
let report = reporter || function() {}
let formdata = new FormData()
formdata.append('file', file)
let request = new XMLHttpRequest()
let finished = false
let lastMarker = new Date()
let lastMarkerLoaded = 0
let lastMarkerSpeed = '...'
request.abortRequest = function() {
finished = true
request.abort()
}
request.upload.addEventListener('progress', function (e) {
let check = new Date()
if (check - lastMarker >= 1000) {
let loaded = e.loaded - lastMarkerLoaded
lastMarkerSpeed = api.prettyFormatBytes(loaded / ((check - lastMarker) / 1000))
lastMarker = check
lastMarkerLoaded = e.loaded
}
report(request, Math.min(e.loaded / file.size * 100, 100), lastMarkerSpeed)
})
request.addEventListener('abort', function(e) {
finished = true
window.requestAnimationFrame(m.redraw.bind(m))
rej()
})
request.addEventListener('readystatechange', function(e) {
if (finished) return
if (request.readyState !== 4) return
finished = true
let out = safeParseReponse(request.responseText, request.status, options.url)
if (out instanceof Error || request.status >= 300) {
return rej(out)
}
return res(out)
})
request.open(options.method || 'POST', options.url)
request.send(formdata)
report(request, 0)
})
.then(function(res) {
api.loading = false
window.requestAnimationFrame(m.redraw.bind(m))
return res
}, function(err) {
api.loading = false
window.requestAnimationFrame(m.redraw.bind(m))
return Promise.reject(err)
})
},
} }
module.exports = api module.exports = api

View file

@ -35,6 +35,17 @@ const Authentication = {
return localStorage.getItem(storageName) return localStorage.getItem(storageName)
}, },
getTokenDecoded: function() {
let token = Authentication.getToken()
var base64Url = token.split('.')[1];
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
var jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
},
requiresLogin: function() { requiresLogin: function() {
if (Authentication.currentUser) return if (Authentication.currentUser) return
m.route.set('/') m.route.set('/')

View file

@ -7,15 +7,17 @@ const Menu = {
oninit: function(vnode) { oninit: function(vnode) {
this.currentActive = 'home' this.currentActive = 'home'
this.loading = false this.loading = false
this.browsing = true
if (!videos.Tree.length) {
videos.refreshTree()
}
this.onbeforeupdate() this.onbeforeupdate()
}, },
onbeforeupdate: function() { onbeforeupdate: function() {
videos.calculateActiveBranches()
let currentPath = m.route.get() let currentPath = m.route.get()
this.browsing = currentPath === '/' || currentPath === '/browse' || videos.year
/*if (currentPath === '/') this.currentActive = 'home'
else if (currentPath === '/login') this.currentActive = 'login'
else if (currentPath && currentPath.startsWith('/page')) this.currentActive = currentPath.slice(currentPath.lastIndexOf('/') + 1)*/
}, },
logOut: function() { logOut: function() {
@ -24,6 +26,10 @@ const Menu = {
}, },
view: function() { view: function() {
let tree = videos.Tree
let last = videos.Tree[videos.Tree.length - 1]
let hasId = m.route.param('id')
return Authentication.currentUser return Authentication.currentUser
? [ ? [
m('nav', [ m('nav', [
@ -32,8 +38,33 @@ const Menu = {
Authentication.currentUser.rank > 10 Authentication.currentUser.rank > 10
? m(m.route.Link, { class: 'upload', href: '/upload' }, lang.upload_goto) // Upload ? m(m.route.Link, { class: 'upload', href: '/upload' }, lang.upload_goto) // Upload
: null, : null,
m('button.logout', { onclick: this.logOut }, lang.header_logout), // Log out // m('button.logout', { onclick: this.logOut }, lang.header_logout), // Log out
]),
!this.browsing && videos.error
? m('div.error', { onclick: videos.refreshTree }, [
videos.error, m('br'), 'Click here to try again'
]) ])
: null,
this.browsing && !videos.error
? [
m('.nav', m('.inner', tree.map(year => {
return m(m.route.Link, {
class: [videos.year === year ? 'active' : '',
!year.videos.length ? 'empty' : ''].join(' '),
href: ['', (videos.year !== year && year !== last) || hasId ? year.title : 'browse' ].filter(x => x).join('/'),
}, year.title)
}))),
videos.year
? m('.nav', m('.inner', videos.year.branches.map(month => {
return m(m.route.Link, {
class: [videos.month === month ? 'active' : '',
!month.videos.length ? 'empty' : ''].join(' '),
href: ['', videos.year.title, videos.month !== month || hasId ? month.title : null ].filter(x => x).join('/'),
}, lang.months[month.title])
})))
: null,
]
: null,
] ]
: null : null
}, },

View file

@ -4,6 +4,7 @@ const Header = require('./header')
const Login = require('./page_login') const Login = require('./page_login')
const Browse = require('./page_browse') const Browse = require('./page_browse')
const Upload = require('./page_upload') const Upload = require('./page_upload')
const Article = require('./page_article')
window.m = m window.m = m
let css = [ let css = [
@ -38,6 +39,9 @@ const allRoutes = {
'/': Login, '/': Login,
'/browse': Browse, '/browse': Browse,
'/upload': Upload, '/upload': Upload,
'/:year': Browse,
'/:year/:month': Browse,
'/:year/:month/:id': Article,
} }
// Wait until we finish checking avif support, some views render immediately and will ask for this immediately before the callback gets called. // Wait until we finish checking avif support, some views render immediately and will ask for this immediately before the callback gets called.

View file

@ -1,29 +1,8 @@
const m = require('mithril') const m = require('mithril')
const popper = require('@popperjs/core') const api = require('./api')
const tempus = require('@eonasdan/tempus-dominus') const tempus = require('@eonasdan/tempus-dominus')
const Input = { const tempusLocalization = {
oninit: function(vnode) {
this.tempus = null
this.subscription = null
},
onremove: function(vnode) {
if (!this.tempus) return
this.tempus.dispose()
this.tempus = null
},
getInput: function(vnode) {
switch (vnode.attrs.utility) {
case 'datetime':
return m('input', {
type: 'text',
oncreate: (e) => {
this.tempus = new tempus.TempusDominus(e.dom, {
defaultDate: vnode.attrs.form[vnode.attrs.formKey],
viewDate: vnode.attrs.form[vnode.attrs.formKey],
localization: {
locale: 'is', locale: 'is',
startOfTheWeek: 0, startOfTheWeek: 0,
hourCycle: 'h23', hourCycle: 'h23',
@ -35,15 +14,62 @@ const Input = {
LLL: 'd [de] MMMM [de] yyyy H:mm', LLL: 'd [de] MMMM [de] yyyy H:mm',
LLLL: 'dddd, d [de] MMMM [de] yyyy H:mm', LLLL: 'dddd, d [de] MMMM [de] yyyy H:mm',
}, },
}
const Input = {
oninit: function(vnode) {
this.tempus = null
this.subscription = null
this.input = null
}, },
onremove: function(vnode) {
if (!this.tempus) return
if (this.subscription) this.subscription.unsubscribe()
this.tempus.dispose()
this.tempus = null
},
getInput: function(vnode) {
switch (vnode.attrs.utility) {
case 'file':
return m('div.form-row', [
m('input', {
type: 'text',
disabled: api.loading,
value: vnode.attrs.form[vnode.attrs.formKey]?.name || '',
}),
m('button.fal', { class: vnode.attrs.button || 'file' }),
m('input.cover', {
type: 'file',
accept: vnode.attrs.accept,
disabled: api.loading,
oninput: (e) => { vnode.attrs.form[vnode.attrs.formKey] = e.currentTarget.files?.[0] || null },
}),
])
case 'datetime':
return m('div.form-row', [
m('input', {
type: 'text',
disabled: api.loading,
oncreate: (e) => {
this.tempus = new tempus.TempusDominus(e.dom, {
defaultDate: vnode.attrs.form[vnode.attrs.formKey],
viewDate: vnode.attrs.form[vnode.attrs.formKey],
localization: tempusLocalization,
}) })
this.subscription = this.tempus.subscribe(tempus.Namespace.events.change, (e) => { this.subscription = this.tempus.subscribe(tempus.Namespace.events.change, (e) => {
console.log(e); vnode.attrs.form[vnode.attrs.formKey] = e.date
}); });
}, },
}),
m('button.fal.fa-calendar', {
onclick: () => { this.tempus.toggle(); return false },
}) })
])
default: default:
return m('input', { return m('input', {
disabled: api.loading,
type: vnode.attrs.type || 'text', type: vnode.attrs.type || 'text',
value: vnode.attrs.form[vnode.attrs.formKey], value: vnode.attrs.form[vnode.attrs.formKey],
oninput: (e) => { vnode.attrs.form[vnode.attrs.formKey] = e.currentTarget.value }, oninput: (e) => { vnode.attrs.form[vnode.attrs.formKey] = e.currentTarget.value },

View file

@ -33,8 +33,44 @@ const i18n = {
'Lykilorð'], 'Lykilorð'],
login_submit: ['Log in', login_submit: ['Log in',
'Skrá inn'], 'Skrá inn'],
login_footer: ['Photo by {0} on {1}', upload_missing_title: ['Title is missing',
'Titill vantar'],
upload_missing_date: ['Date is missing',
'Dagsetning vantar'],
upload_missing_file: ['Video file missing',
'Myndaskrá vantar'],
upload_error: ['Error while uploading: {0}',
'Villa við að hlaða upp myndefni: {0}'],
unsplash: ['Photo by {0} on {1}',
'Mynd eftir {0} frá {1}'], 'Mynd eftir {0} frá {1}'],
api_down: ['No internet or browser blocked the request.',
'Ekkert net eða vafri blockaði fyrirspurn.'],
months: {
'1': ['January',
'Janúar'],
'2': ['February',
'Febrúar'],
'3': ['March',
'Mars'],
'4': ['April',
'Apríl'],
'5': ['May',
'Maí'],
'6': ['June',
'Júní'],
'7': ['July',
'Júlí'],
'8': ['August',
'Ágúst'],
'9': ['September',
'September'],
'10': ['Oktober',
'Október'],
'11': ['November',
'Nóvember'],
'12': ['December',
'Desember'],
},
} }
const langs = { const langs = {
'en': 0, 'en': 0,
@ -46,10 +82,17 @@ const regexNumber = new RegExp('^\\d+$')
out.langset = function(lang) { out.langset = function(lang) {
out.currentlang = lang out.currentlang = lang
let index = langs[lang] let index = langs[lang]
let keys = Object.keys(i18n)
for (let key of keys) { for (let key of Object.keys(i18n)) {
if (!Array.isArray(i18n[key])) {
out[key] = {}
for (let subKey of Object.keys(i18n[key])) {
out[key][subKey] = i18n[key][subKey][index]
}
} else {
out[key] = i18n[key][index] out[key] = i18n[key][index]
} }
}
} }
out.langtoggle = function() { out.langtoggle = function() {

View file

@ -0,0 +1,74 @@
const m = require('mithril')
const api = require('./api')
const Authentication = require('./authentication')
const lang = require('./lang')
const Article = {
oninit: function(vnode) {
Authentication.requiresLogin()
this.error = ''
this.path = ''
this.data = null
this.onbeforeupdate(vnode)
},
onbeforeupdate: function(vnode) {
let path = m.route.param('id')
if (this.path === path) return
this.fetchArticle(vnode, path)
},
fetchArticle: function(vnode, path) {
this.error = ''
this.data = null
this.path = path
api.sendRequest({
method: 'GET',
url: '/api/auth/articles/' + this.path,
})
.then((result) => {
this.data = result.article
this.afterData()
}, (err) => {
this.error = err.message
})
},
afterData: function() {
if (!this.data) {
this.error = 'Article not found'
}
},
view: function(vnode) {
console.log(this.data)
return [
api.loading ? m('div.loading-spinner') : null,
this.error
? m('div.full-error', { onclick: this.fetchArticle.bind(this, vnode, this.path) }, [
this.error, m('br'), 'Click here to try again'
])
: null,
this.data?.media_path
? [
m('.player', [
m('video', {
crossorigin: '',
controls: true,
preload: 'none',
poster: '/assets/placeholder.avif',
}, [
m('source', {
src: this.data.media_path
})
]),
]),
]
: null,
]
},
}
module.exports = Article

View file

@ -1,26 +1,58 @@
const m = require('mithril') const m = require('mithril')
const api = require('./api')
const Authentication = require('./authentication') const Authentication = require('./authentication')
const videos = require('./videos') const videos = require('./videos')
const lang = require('./lang')
const Browse = { const Browse = {
oninit: function(vnode) { oninit: function(vnode) {
Authentication.requiresLogin() Authentication.requiresLogin()
if (!videos.Tree.length) {
this.refreshTree()
}
}, },
refreshTree: function(vnode) { mArticles: function(vnode, articles) {
videos.refreshTree() return articles.map(article => {
return m(m.route.Link, { href: ['', article.publish_at.getFullYear(), article.publish_at.getMonth() + 1, article.id].join('/') }, [
m('span', article.publish_at.toUTCString()),
m('span', article.name),
])
})
},
mMonth: function(vnode, year) {
return year.branches.map(month => {
return [
m('.gallery-month', lang.months[month.title]),
m('.group', this.mArticles(vnode, month.videos))
]
})
}, },
view: function(vnode) { view: function(vnode) {
let articles = videos.month?.videos || videos.year?.videos || videos.Articles
return [ return [
api.loading ? m('div.loading-spinner') : null,
videos.error videos.error
? m('div.full-error', { onclick: this.refreshTree.bind(this) }, [ ? m('div.full-error', { onclick: videos.refreshTree }, [
videos.error, m('br'), 'Click here to try again' videos.error, m('br'), 'Click here to try again'
]) ])
: null, : null,
m('.gallery', [
videos.month
? m('.group', this.mArticles(vnode, articles))
: null,
videos.year && !videos.month
? this.mMonth(vnode, videos.year)
: null,
!videos.year
? videos.Tree.slice(-2).map(year => {
return [
m('.gallery-year', year.title),
this.mMonth(vnode, year),
]
})
: null
]),
] ]
}, },
} }

View file

@ -3,6 +3,7 @@ const Authentication = require('./authentication')
const api = require('./api') const api = require('./api')
const Input = require('./input') const Input = require('./input')
const lang = require('./lang') const lang = require('./lang')
const videos = require('./videos')
const Login = { const Login = {
oninit: function(vnode) { oninit: function(vnode) {
@ -34,6 +35,7 @@ const Login = {
if (!result.token) return Promise.reject(new Error(lang.login_error_auth)) // Unknown error from server. Try again later if (!result.token) return Promise.reject(new Error(lang.login_error_auth)) // Unknown error from server. Try again later
Authentication.updateToken(result.token) Authentication.updateToken(result.token)
m.route.set(this.redirect || '/browse') m.route.set(this.redirect || '/browse')
videos.refreshTree()
}) })
.catch((error) => { .catch((error) => {
this.error = lang.format(lang.login_error, error.message) // Error while logging in: this.error = lang.format(lang.login_error, error.message) // Error while logging in:
@ -73,7 +75,7 @@ const Login = {
]), ]),
]), ]),
m('footer', lang.mformat( m('footer', lang.mformat(
lang.login_footer, // Photo by X on Y lang.unsplash, // Photo by X on Y
m('a', { href: 'https://unsplash.com/@franhotchin?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash', target: '_blank' }, 'Francesca Hotchin'), m('a', { href: 'https://unsplash.com/@franhotchin?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash', target: '_blank' }, 'Francesca Hotchin'),
m('a', { href: 'https://unsplash.com/photos/landscape-photo-of-mountain-covered-with-snow-FN-cedy6NHA?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash', target: '_blank' }, 'Unsplash'), m('a', { href: 'https://unsplash.com/photos/landscape-photo-of-mountain-covered-with-snow-FN-cedy6NHA?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash', target: '_blank' }, 'Unsplash'),
)), )),

View file

@ -2,6 +2,8 @@ const m = require('mithril')
const Authentication = require('./authentication') const Authentication = require('./authentication')
const api = require('./api') const api = require('./api')
const Input = require('./input') const Input = require('./input')
const lang = require('./lang')
const videos = require('./videos')
const Upload = { const Upload = {
oninit: function(vnode) { oninit: function(vnode) {
@ -14,15 +16,95 @@ const Upload = {
d.setSeconds(0) d.setSeconds(0)
d.setMilliseconds(0) d.setMilliseconds(0)
this.cache = null
this.uploading = null
this.form = { this.form = {
title: '', title: 'Sunnudagssamkoma',
date: d, date: d,
file: null, file: null,
metadata: {
speaker: '',
},
} }
}, },
uploadvideo: function(vnode, e) { uploadvideo: function(vnode, e) {
console.log(this.form) this.error = ''
if (!this.form.title) this.error = lang.upload_missing_title // Title is missing
if (!this.form.date) this.error = lang.upload_missing_date // Date is missing
if (!this.form.file) this.error = lang.upload_missing_file // Video file missing
if (this.error) return false
api.sendRequest({
method: 'GET',
url: '/api/auth/uploadToken',
body: this.form,
})
.then(res => {
if (this.cache?.file === this.form.file) {
return this.cache
}
return api.uploadFileProgress({
url: res.path + '?token=' + res.token,
}, this.form.file, (xhr, progress, perSecond) => {
this.uploading = {
progress,
xhr,
perSecond
}
m.redraw()
})
})
.then(res => {
this.cache = {
file: this.form.file,
filename: res.filename,
path: res.path,
}
this.uploading = null
return api.sendRequest({
method: 'PUT',
url: '/api/auth/articles/0',
body: {
name: this.form.title,
page_id: 'null',
path: this.form.date.toISOString().replace('T', '_').replace(/:/g, '').split('.')[0],
content: JSON.stringify(this.form.metadata),
publish_at: this.form.date,
admin_id: Authentication.getTokenDecoded().user_id,
is_featured: false,
media: {
filename: res.filename,
type: this.form.file.type,
path: res.path,
size: this.form.file.size,
}
},
})
})
.then(res => {
console.log(res)
videos.refreshTree()
m.route.set('/browse')
})
.catch((error) => {
this.uploading = null
if (!error) return
this.error = lang.format(lang.upload_error, error.message) // Error while uploading:
})
return false
},
cancelUpload(e) {
e.stopPropagation()
this.uploading.xhr.abortRequest()
this.uploading = null
return false return false
}, },
@ -41,20 +123,47 @@ const Upload = {
formKey: 'title', formKey: 'title',
}), }),
m(Input, { m(Input, {
label: 'Date', label: 'Date (dd.mm.yyyy)',
type: 'text', type: 'text',
utility: 'datetime', utility: 'datetime',
form: this.form, form: this.form,
formKey: 'date', formKey: 'date',
}), }),
m(Input, {
label: 'Video',
type: 'file',
accept: '.webm',
utility: 'file',
button: 'fa-video',
form: this.form,
formKey: 'file',
}),
m('p.separator', 'Optional'),
m(Input, {
label: 'Speaker',
form: this.form.metadata,
formKey: 'speaker',
}),
m('input.spinner', { m('input.spinner', {
hidden: api.loading, hidden: api.loading,
type: 'submit', type: 'submit',
value: 'Begin upload', value: 'Begin upload',
}), }),
api.loading ? m('div.loading-spinner') : null, api.loading ? m('div.loading-spinner') : null,
this.uploading ? [
m('p', `${Math.floor(this.uploading.progress)}% (${this.uploading.perSecond}/s)`),
m('.loading-bar', { style: `--progress: ${this.uploading.progress}%` }),
m('button.button.button-alert', {
onclick: this.cancelUpload.bind(this),
}, 'Cancel upload'),
] : null,
]), ]),
]), ]),
m('footer', lang.mformat(
lang.unsplash, // Photo by X on Y
m('a', { href: 'https://unsplash.com/@guzmanbarquin?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash', target: '_blank' }, 'Guzmán Barquín'),
m('a', { href: 'https://unsplash.com/photos/sea-under-full-moon-Qd688l1yDOI?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash', target: '_blank' }, 'Unsplash'),
)),
]), ]),
] ]
}, },

View file

@ -2,13 +2,79 @@ const m = require('mithril')
const api = require('./api') const api = require('./api')
const Tree = [] const Tree = []
const Articles = []
exports.Tree = Tree exports.Tree = Tree
exports.Articles = Articles
exports.loading = false exports.loading = false
exports.error = '' exports.error = ''
exports.year = null
exports.month = null
exports.refreshTree = function() { const matcher = /\/(\d+)(\/\d+)?/
function calculateActiveBranches() {
let path = matcher.exec(m.route.get())
if (path && path[1] !== exports.year?.title) {
for (let year of Tree) {
if (year.title === path[1]) {
exports.year = year
break
}
}
} else if (!path && (m.route.get() === '/' || (m.route.get() || '').startsWith('/browse'))) {
exports.year = Tree[Tree.length - 1]
} else if (!path) {
exports.year = null
}
if (path && exports.year && path[2]) {
exports.month = exports.year.branches[Number(path[2].slice(1)) - 1] || null
} else if (!path?.[2]) {
exports.month = null
}
}
function rebuildTree() {
Tree.splice(0, Tree.length)
if (!Articles.length) return
let startYear = Articles[0].publish_at
let target = new Date()
let articleIndex = 0
for (let year = startYear.getFullYear(); year <= target.getFullYear(); year++) {
let branchYear = {
title: year.toString(),
type: 'year',
branches: [],
videos: []
}
Tree.push(branchYear)
let lastMonth = year === target.getFullYear() ? target.getMonth() + 1 : 12
for (let month = 1; month <= lastMonth; month++) {
let branchMonth = {
title: month.toString(),
type: 'month',
branches: [],
videos: []
}
branchYear.branches.push(branchMonth)
let start = new Date(year, month - 1)
let end = new Date(year, month)
for (; Articles[articleIndex] && Articles[articleIndex].publish_at >= start && Articles[articleIndex].publish_at < end; articleIndex++) {
branchYear.videos.push(Articles[articleIndex])
branchMonth.videos.push(Articles[articleIndex])
}
}
}
}
function refreshTree() {
exports.error = '' exports.error = ''
if (exports.loading) return Promise.resolve() if (exports.loading) return Promise.resolve()
@ -19,17 +85,27 @@ exports.refreshTree = function() {
return api.sendRequest({ return api.sendRequest({
method: 'GET', method: 'GET',
url: '/api/videos', url: '/api/auth/articles',
}) })
.then(pages => { .then(result => {
console.log(pages) result.videos.forEach(video => {
Tree.splice(0, Tree.length) video.publish_at = new Date(video.publish_at)
Tree.push.apply(Tree, pages.videos) video.path_short = video.path.split('-')[2]
exports.loading = false })
m.redraw()
Articles.splice(0, Articles.length)
Articles.push.apply(Articles, result.videos)
rebuildTree()
}, err => { }, err => {
exports.loading = false
m.redraw()
exports.error = 'Error fetching videos: ' + err.message exports.error = 'Error fetching videos: ' + err.message
}) })
.then(() => {
exports.loading = false
m.redraw()
})
} }
exports.rebuildTree = rebuildTree
exports.refreshTree = refreshTree
exports.calculateActiveBranches = calculateActiveBranches

View file

@ -1,24 +0,0 @@
import fs from 'fs'
import { ServiceCore } from 'service-core'
import * as index from './index.mjs'
const port = 4130
var core = new ServiceCore('filadelfia_web', import.meta.url, port, '')
let config = {
frontend: {
url: 'http://localhost:' + port
}
}
try {
config = JSON.parse(fs.readFileSync('./config.json'))
} catch {}
config.port = port
core.setConfig(config)
core.init(index).then(function() {
return core.run()
})

View file

@ -1,3 +1,5 @@
import fs from 'fs'
import { pathToFileURL } from 'url'
import config from './base/config.mjs' import config from './base/config.mjs'
export function start(http, port, ctx) { export function start(http, port, ctx) {
@ -9,3 +11,28 @@ export function start(http, port, ctx) {
return server.run() return server.run()
}) })
} }
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
import('service-core').then(core => {
const port = 4130
var core = new core.ServiceCore('filadelfia_web', import.meta.url, port, '')
let config = {
frontend: {
url: 'http://localhost:' + port
}
}
try {
config = JSON.parse(fs.readFileSync('./config.json'))
} catch {}
config.port = port
core.setConfig(config)
core.init({ start }).then(function() {
return core.run()
})
})
}

View file

@ -16,7 +16,7 @@
"dev:server": "eltro --watch server --npm server", "dev:server": "eltro --watch server --npm server",
"dev:build:old": "npm-watch build", "dev:build:old": "npm-watch build",
"dev:server:old": "npm-watch server", "dev:server:old": "npm-watch server",
"server": "node dev.mjs | bunyan" "server": "node index.mjs | bunyan"
}, },
"watch": { "watch": {
"server": { "server": {

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

View file

@ -16,6 +16,7 @@
--bg-component-half: #f3f7ff77; --bg-component-half: #f3f7ff77;
--bg-component-alt: #ffd99c; --bg-component-alt: #ffd99c;
--color: #031131; --color: #031131;
--color-alt: #7a9ad3;
--main: #1066ff; --main: #1066ff;
--main-fg: #fff; --main-fg: #fff;
--error: red; --error: red;
@ -92,6 +93,46 @@ input[type=datetime] {
width: 100%; width: 100%;
} }
input[type=text]:disabled,
input[type=password]:disabled,
input[type=datetime]:disabled {
background: var(--bg-component);
border-color: var(--color-alt);
color: var(--color-alt);
}
.form-row input:disabled + button {
border-color: var(--color-alt);
}
.form-row {
display: flex;
position: relative;
}
.form-row input {
flex: 2 1 auto;
}
.form-row button {
min-width: 30px;
text-align: center;
border: 1px solid var(--main);
border-left: none;
background: var(--bg-component);
text-decoration: none;
}
.form-row .cover {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.button, input[type=submit] { .button, input[type=submit] {
background: var(--main); background: var(--main);
color:var(--main-fg); color:var(--main-fg);
@ -101,14 +142,35 @@ input[type=datetime] {
margin: 1rem 0 2rem; margin: 1rem 0 2rem;
align-self: center; align-self: center;
cursor: pointer; cursor: pointer;
text-decoration: none;
} }
.button.spinner, input[type=submit].spinner { .button.spinner, input[type=submit].spinner {
height: 2rem; height: 2rem;
margin-top: 1.5rem; margin-top: 2rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.button-alert {
background: var(--error-bg);
color: var(--color);
}
.loading-bar {
border: 1px solid var(--main);
background: var(--bg-component);
}
.loading-bar::after {
height: 1rem;
background: var(--main);
min-width: 1px;
content: '';
display: block;
width: var(--progress);
transition: width 3s;
}
input[type=text]:focus, input[type=text]:focus,
input[type=password]:focus, input[type=password]:focus,
input[type=datetime]:focus { input[type=datetime]:focus {
@ -147,10 +209,17 @@ h1 {
form p, label { form p, label {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
margin: 0.5rem 0; margin: 0.75rem 0 0.5rem 0;
display: block; display: block;
} }
form p.separator {
color: var(--color-alt);
margin-top: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-alt);
}
.error { .error {
color: var(--error); color: var(--error);
} }
@ -170,6 +239,7 @@ form p, label {
display: inline-block; display: inline-block;
width: 80px; width: 80px;
height: 80px; height: 80px;
margin-top: 0.5rem;
align-self: center; align-self: center;
} }
.loading-spinner:after { .loading-spinner:after {
@ -192,11 +262,37 @@ form p, label {
} }
} }
.page-upload {
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.page-upload footer {
backdrop-filter: blur(10px);
background: var(--bg-component-half);
align-self: center;
padding: 0.25rem 2rem;
border-radius: 1rem;
margin-bottom: 1rem;
}
.avifsupport .page-upload {
background-image: url('./assets/bg_admin.avif');
}
.jpegonly .page-upload {
background-image: url('./assets/bg_admin.jpg');
}
/* Nav */ /* Nav */
#header nav { #header {
display: flex;
background: var(--bg-component); background: var(--bg-component);
}
#header nav,
#header .nav {
display: flex;
padding: 0.5rem 1rem 0.5rem 0; padding: 0.5rem 1rem 0.5rem 0;
} }
@ -238,6 +334,49 @@ form p, label {
color: var(--main-fg); color: var(--main-fg);
} }
#header .nav {
overflow-x: hidden;
padding: 0.75rem 1rem 0.25rem;
min-height: 4rem;
align-items: flex-start;
border-bottom: 1px solid #0001;
}
#header .nav:hover {
overflow-x: auto;
}
#header .nav .inner {
flex: 2 1 auto;
display: flex;
justify-content: center;
}
#header .nav a {
margin: 0 0.25rem;
padding: 0.25rem 1.5rem;
border-radius: 3rem;
}
#header .nav a.empty {
opacity: 0.5;
}
#header .nav a.active {
background: var(--bg-component-alt);
color: var(--color);
text-decoration: none;
}
#header .error {
background: var(--error-bg);
color: var(--color);
font-size: 0.8rem;
text-align: center;
padding: 0.25rem;
cursor: pointer;
}
/* Main */ /* Main */
.full-error { .full-error {
@ -288,6 +427,65 @@ footer a {
background-image: url('./assets/bg.jpg'); background-image: url('./assets/bg.jpg');
} }
/* browse */
.gallery {
margin: 1rem;
display: flex;
flex-direction: column;
}
.gallery-year {
margin: 1rem;
padding: 0 0 1rem;
border-bottom: 1px solid var(--main);
text-align: center;
font-size: 2rem;
}
.gallery-month {
margin: 0rem 1rem 1rem;
font-size: 1.2rem;
border-bottom: 1px solid #0003;
}
.gallery .group {
display: flex;
flex-wrap: wrap;
}
.gallery .group a {
width: 40vw;
max-width: 320px;
aspect-ratio: 16 / 9;
display: flex;
flex-direction: column;
justify-content: flex-end;
background: url('./assets/placeholder.avif') center no-repeat;
background-size: cover;
padding: 1rem;
margin: 0 1rem 1rem;
text-align: center;
border: 1px solid var(--main);
text-shadow: 2px 0 10px #fffc, -2px 0 10px #fffc, 0 2px 10px #fffc, 0 -2px 10px #fffc,
1px 1px 10px #fffc, -1px -1px 10px #fffc, 1px -1px 10px #fffc, -1px 1px 10px #fffc;
}
/* Player */
.player {
background: black;
text-align: center;
margin-bottom: 1rem;
}
.player video {
margin: 0 auto;
width: 1280px;
max-width: 100vw;
aspect-ratio: 16 / 9;
}
</style> </style>
</head> </head>

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

View file

@ -14,7 +14,7 @@
"build": "asbundle app/index.js public/assets/app.js && asbundle app/admin/admin.js public/assets/admin.js", "build": "asbundle app/index.js public/assets/app.js && asbundle app/admin/admin.js public/assets/admin.js",
"dev:build": "npm-watch build", "dev:build": "npm-watch build",
"dev:server": "node index.mjs | bunyan", "dev:server": "node index.mjs | bunyan",
"dev": "npm-watch dev:server", "dev": "npm-watch dev:server"
}, },
"watch": { "watch": {
"dev:server": { "dev:server": {