diff --git a/filadelfia_web/api/article/routes.mjs b/filadelfia_web/api/article/routes.mjs new file mode 100644 index 0000000..0fc3d2c --- /dev/null +++ b/filadelfia_web/api/article/routes.mjs @@ -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) + } +} diff --git a/filadelfia_web/api/video/util.mjs b/filadelfia_web/api/article/util.mjs similarity index 71% rename from filadelfia_web/api/video/util.mjs rename to filadelfia_web/api/article/util.mjs index 6a84665..05a01b2 100644 --- a/filadelfia_web/api/video/util.mjs +++ b/filadelfia_web/api/article/util.mjs @@ -12,5 +12,8 @@ export function parseVideo(video) { return null } parseMediaAndBanner(video) + video.metadata = JSON.parse(video.metadata || '{}') + delete video.banner_alt_prefix + delete video.media_alt_prefix return video } \ No newline at end of file diff --git a/filadelfia_web/api/server.mjs b/filadelfia_web/api/server.mjs index 58aa129..84d1e7f 100644 --- a/filadelfia_web/api/server.mjs +++ b/filadelfia_web/api/server.mjs @@ -3,19 +3,19 @@ import Parent from '../base/server.mjs' import StaticRoutes from '../base/static_routes.mjs' import ServeHandler from './serve.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 { init() { super.init() 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.routes = { static: new StaticRoutes(), auth: new AuthenticationRoutes(), - videos: new VideoRoutes(), + article: new ArticleRoutes(), } this.routes.serve = new ServeHandler({ root: localUtil.getPathFromRoot('../public'), diff --git a/filadelfia_web/api/video/routes.mjs b/filadelfia_web/api/video/routes.mjs deleted file mode 100644 index 169ee72..0000000 --- a/filadelfia_web/api/video/routes.mjs +++ /dev/null @@ -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), - } - } -} diff --git a/filadelfia_web/app/api.js b/filadelfia_web/app/api.js index 703a6d6..1dfc70b 100644 --- a/filadelfia_web/app/api.js +++ b/filadelfia_web/app/api.js @@ -1,4 +1,25 @@ 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) === '= 300) { if (out.message) { @@ -46,6 +46,19 @@ const api = { console.error('Got error ' + xhr.status + ' but no error message:', out) 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 } @@ -67,7 +80,84 @@ const api = { } 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 diff --git a/filadelfia_web/app/authentication.js b/filadelfia_web/app/authentication.js index 8759dd3..768e43b 100644 --- a/filadelfia_web/app/authentication.js +++ b/filadelfia_web/app/authentication.js @@ -35,6 +35,17 @@ const Authentication = { 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() { if (Authentication.currentUser) return m.route.set('/') diff --git a/filadelfia_web/app/header.js b/filadelfia_web/app/header.js index fe2d475..3dc217e 100644 --- a/filadelfia_web/app/header.js +++ b/filadelfia_web/app/header.js @@ -7,15 +7,17 @@ const Menu = { oninit: function(vnode) { this.currentActive = 'home' this.loading = false + this.browsing = true + if (!videos.Tree.length) { + videos.refreshTree() + } this.onbeforeupdate() }, onbeforeupdate: function() { + videos.calculateActiveBranches() let currentPath = m.route.get() - - /*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)*/ + this.browsing = currentPath === '/' || currentPath === '/browse' || videos.year }, logOut: function() { @@ -24,6 +26,10 @@ const Menu = { }, view: function() { + let tree = videos.Tree + let last = videos.Tree[videos.Tree.length - 1] + let hasId = m.route.param('id') + return Authentication.currentUser ? [ m('nav', [ @@ -32,8 +38,33 @@ const Menu = { Authentication.currentUser.rank > 10 ? m(m.route.Link, { class: 'upload', href: '/upload' }, lang.upload_goto) // Upload : 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 }, diff --git a/filadelfia_web/app/index.js b/filadelfia_web/app/index.js index 323bf87..f39f7c6 100644 --- a/filadelfia_web/app/index.js +++ b/filadelfia_web/app/index.js @@ -4,6 +4,7 @@ const Header = require('./header') const Login = require('./page_login') const Browse = require('./page_browse') const Upload = require('./page_upload') +const Article = require('./page_article') window.m = m let css = [ @@ -38,6 +39,9 @@ const allRoutes = { '/': Login, '/browse': Browse, '/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. diff --git a/filadelfia_web/app/input.js b/filadelfia_web/app/input.js index d0273d7..233c951 100644 --- a/filadelfia_web/app/input.js +++ b/filadelfia_web/app/input.js @@ -1,49 +1,75 @@ const m = require('mithril') -const popper = require('@popperjs/core') +const api = require('./api') const tempus = require('@eonasdan/tempus-dominus') +const tempusLocalization = { + locale: 'is', + startOfTheWeek: 0, + hourCycle: 'h23', + dateFormats: { + LTS: 'H:mm:ss', + LT: 'H:mm', + L: 'dd.MM.yyyy', + LL: 'd [de] MMMM [de] yyyy', + LLL: '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('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', - startOfTheWeek: 0, - hourCycle: 'h23', - dateFormats: { - LTS: 'H:mm:ss', - LT: 'H:mm', - L: 'dd.MM.yyyy', - LL: 'd [de] MMMM [de] yyyy', - LLL: 'd [de] MMMM [de] yyyy H:mm', - LLLL: 'dddd, d [de] MMMM [de] yyyy H:mm', - }, - }, - }) - this.subscription = this.tempus.subscribe(tempus.Namespace.events.change, (e) => { - console.log(e); - }); - }, - }) + 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) => { + vnode.attrs.form[vnode.attrs.formKey] = e.date + }); + }, + }), + m('button.fal.fa-calendar', { + onclick: () => { this.tempus.toggle(); return false }, + }) + ]) default: return m('input', { + disabled: api.loading, type: vnode.attrs.type || 'text', value: vnode.attrs.form[vnode.attrs.formKey], oninput: (e) => { vnode.attrs.form[vnode.attrs.formKey] = e.currentTarget.value }, diff --git a/filadelfia_web/app/lang.js b/filadelfia_web/app/lang.js index eed6a05..934b1f0 100644 --- a/filadelfia_web/app/lang.js +++ b/filadelfia_web/app/lang.js @@ -33,8 +33,44 @@ const i18n = { 'Lykilorð'], login_submit: ['Log in', '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}'], + 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 = { 'en': 0, @@ -46,9 +82,16 @@ const regexNumber = new RegExp('^\\d+$') out.langset = function(lang) { out.currentlang = lang let index = langs[lang] - let keys = Object.keys(i18n) - for (let key of keys) { - out[key] = i18n[key][index] + + 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] + } } } diff --git a/filadelfia_web/app/page_article.js b/filadelfia_web/app/page_article.js new file mode 100644 index 0000000..a074a5f --- /dev/null +++ b/filadelfia_web/app/page_article.js @@ -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 diff --git a/filadelfia_web/app/page_browse.js b/filadelfia_web/app/page_browse.js index d676d4f..833f7e9 100644 --- a/filadelfia_web/app/page_browse.js +++ b/filadelfia_web/app/page_browse.js @@ -1,26 +1,58 @@ const m = require('mithril') +const api = require('./api') const Authentication = require('./authentication') const videos = require('./videos') +const lang = require('./lang') const Browse = { oninit: function(vnode) { Authentication.requiresLogin() - if (!videos.Tree.length) { - this.refreshTree() - } }, - refreshTree: function(vnode) { - videos.refreshTree() + mArticles: function(vnode, articles) { + 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) { + let articles = videos.month?.videos || videos.year?.videos || videos.Articles + return [ + api.loading ? m('div.loading-spinner') : null, 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' ]) : 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 + ]), ] }, } diff --git a/filadelfia_web/app/page_login.js b/filadelfia_web/app/page_login.js index b473dbb..85023d3 100644 --- a/filadelfia_web/app/page_login.js +++ b/filadelfia_web/app/page_login.js @@ -3,6 +3,7 @@ const Authentication = require('./authentication') const api = require('./api') const Input = require('./input') const lang = require('./lang') +const videos = require('./videos') const Login = { 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 Authentication.updateToken(result.token) m.route.set(this.redirect || '/browse') + videos.refreshTree() }) .catch((error) => { this.error = lang.format(lang.login_error, error.message) // Error while logging in: @@ -73,7 +75,7 @@ const Login = { ]), ]), 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/photos/landscape-photo-of-mountain-covered-with-snow-FN-cedy6NHA?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash', target: '_blank' }, 'Unsplash'), )), diff --git a/filadelfia_web/app/page_upload.js b/filadelfia_web/app/page_upload.js index 7daaad2..f186cae 100644 --- a/filadelfia_web/app/page_upload.js +++ b/filadelfia_web/app/page_upload.js @@ -2,6 +2,8 @@ const m = require('mithril') const Authentication = require('./authentication') const api = require('./api') const Input = require('./input') +const lang = require('./lang') +const videos = require('./videos') const Upload = { oninit: function(vnode) { @@ -14,15 +16,95 @@ const Upload = { d.setSeconds(0) d.setMilliseconds(0) + this.cache = null + this.uploading = null this.form = { - title: '', + title: 'Sunnudagssamkoma', date: d, file: null, + metadata: { + speaker: '', + }, } }, 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 }, @@ -41,20 +123,47 @@ const Upload = { formKey: 'title', }), m(Input, { - label: 'Date', + label: 'Date (dd.mm.yyyy)', type: 'text', utility: 'datetime', form: this.form, 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', { hidden: api.loading, type: 'submit', value: 'Begin upload', }), 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'), + )), ]), ] }, diff --git a/filadelfia_web/app/videos.js b/filadelfia_web/app/videos.js index 3c86d04..535422a 100644 --- a/filadelfia_web/app/videos.js +++ b/filadelfia_web/app/videos.js @@ -2,13 +2,79 @@ const m = require('mithril') const api = require('./api') const Tree = [] +const Articles = [] exports.Tree = Tree +exports.Articles = Articles exports.loading = false 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 = '' if (exports.loading) return Promise.resolve() @@ -19,17 +85,27 @@ exports.refreshTree = function() { return api.sendRequest({ method: 'GET', - url: '/api/videos', + url: '/api/auth/articles', }) - .then(pages => { - console.log(pages) - Tree.splice(0, Tree.length) - Tree.push.apply(Tree, pages.videos) - exports.loading = false - m.redraw() + .then(result => { + result.videos.forEach(video => { + video.publish_at = new Date(video.publish_at) + video.path_short = video.path.split('-')[2] + }) + + Articles.splice(0, Articles.length) + Articles.push.apply(Articles, result.videos) + + rebuildTree() }, err => { - exports.loading = false - m.redraw() exports.error = 'Error fetching videos: ' + err.message }) -} \ No newline at end of file + .then(() => { + exports.loading = false + m.redraw() + }) +} + +exports.rebuildTree = rebuildTree +exports.refreshTree = refreshTree +exports.calculateActiveBranches = calculateActiveBranches diff --git a/filadelfia_web/dev.mjs b/filadelfia_web/dev.mjs deleted file mode 100644 index 47cac1a..0000000 --- a/filadelfia_web/dev.mjs +++ /dev/null @@ -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() -}) \ No newline at end of file diff --git a/filadelfia_web/index.mjs b/filadelfia_web/index.mjs index 6a42b60..3c37e77 100644 --- a/filadelfia_web/index.mjs +++ b/filadelfia_web/index.mjs @@ -1,3 +1,5 @@ +import fs from 'fs' +import { pathToFileURL } from 'url' import config from './base/config.mjs' export function start(http, port, ctx) { @@ -9,3 +11,28 @@ export function start(http, port, ctx) { 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() + }) + }) +} \ No newline at end of file diff --git a/filadelfia_web/package.json b/filadelfia_web/package.json index 96cd581..38bb637 100644 --- a/filadelfia_web/package.json +++ b/filadelfia_web/package.json @@ -16,7 +16,7 @@ "dev:server": "eltro --watch server --npm server", "dev:build:old": "npm-watch build", "dev:server:old": "npm-watch server", - "server": "node dev.mjs | bunyan" + "server": "node index.mjs | bunyan" }, "watch": { "server": { diff --git a/filadelfia_web/public/assets/bg_admin.avif b/filadelfia_web/public/assets/bg_admin.avif new file mode 100644 index 0000000..616470e Binary files /dev/null and b/filadelfia_web/public/assets/bg_admin.avif differ diff --git a/filadelfia_web/public/assets/placeholder.avif b/filadelfia_web/public/assets/placeholder.avif new file mode 100644 index 0000000..0a6aac1 Binary files /dev/null and b/filadelfia_web/public/assets/placeholder.avif differ diff --git a/filadelfia_web/public/assets/placeholder.jpg b/filadelfia_web/public/assets/placeholder.jpg new file mode 100644 index 0000000..fab3633 Binary files /dev/null and b/filadelfia_web/public/assets/placeholder.jpg differ diff --git a/filadelfia_web/public/assets/placeholder.png b/filadelfia_web/public/assets/placeholder.png new file mode 100644 index 0000000..f82ce68 Binary files /dev/null and b/filadelfia_web/public/assets/placeholder.png differ diff --git a/filadelfia_web/public/index.html b/filadelfia_web/public/index.html index 1a5873e..22b5186 100644 --- a/filadelfia_web/public/index.html +++ b/filadelfia_web/public/index.html @@ -16,6 +16,7 @@ --bg-component-half: #f3f7ff77; --bg-component-alt: #ffd99c; --color: #031131; + --color-alt: #7a9ad3; --main: #1066ff; --main-fg: #fff; --error: red; @@ -92,6 +93,46 @@ input[type=datetime] { 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] { background: var(--main); color:var(--main-fg); @@ -101,14 +142,35 @@ input[type=datetime] { margin: 1rem 0 2rem; align-self: center; cursor: pointer; + text-decoration: none; } .button.spinner, input[type=submit].spinner { height: 2rem; - margin-top: 1.5rem; + margin-top: 2rem; 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=password]:focus, input[type=datetime]:focus { @@ -147,10 +209,17 @@ h1 { form p, label { font-size: 0.75rem; font-weight: 500; - margin: 0.5rem 0; + margin: 0.75rem 0 0.5rem 0; 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 { color: var(--error); } @@ -170,6 +239,7 @@ form p, label { display: inline-block; width: 80px; height: 80px; + margin-top: 0.5rem; align-self: center; } .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 */ -#header nav { - display: flex; +#header { background: var(--bg-component); +} + +#header nav, +#header .nav { + display: flex; padding: 0.5rem 1rem 0.5rem 0; } @@ -238,6 +334,49 @@ form p, label { 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 */ .full-error { @@ -288,6 +427,65 @@ footer a { 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; +} diff --git a/filadelfia_web/rawassets/bg_admin.jpg b/filadelfia_web/rawassets/bg_admin.jpg new file mode 100644 index 0000000..c9af18a Binary files /dev/null and b/filadelfia_web/rawassets/bg_admin.jpg differ