diff --git a/frontend/polyfills/fetch.js b/frontend/polyfills/fetch.js new file mode 100644 index 000000000..ac9a4fd87 --- /dev/null +++ b/frontend/polyfills/fetch.js @@ -0,0 +1,466 @@ +(function(self) { + 'use strict'; + + if (self.fetch) { + return + } + + var support = { + searchParams: 'URLSearchParams' in self, + iterable: 'Symbol' in self && 'iterator' in Symbol, + blob: 'FileReader' in self && 'Blob' in self && (function() { + try { + new Blob() + return true + } catch(e) { + return false + } + })(), + formData: 'FormData' in self, + arrayBuffer: 'ArrayBuffer' in self + } + + if (support.arrayBuffer) { + var viewClasses = [ + '[object Int8Array]', + '[object Uint8Array]', + '[object Uint8ClampedArray]', + '[object Int16Array]', + '[object Uint16Array]', + '[object Int32Array]', + '[object Uint32Array]', + '[object Float32Array]', + '[object Float64Array]' + ] + + var isDataView = function(obj) { + return obj && DataView.prototype.isPrototypeOf(obj) + } + + var isArrayBufferView = ArrayBuffer.isView || function(obj) { + return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1 + } + } + + function normalizeName(name) { + if (typeof name !== 'string') { + name = String(name) + } + if (/[^a-z0-9\-#$%&'*+.\^_`|~]/i.test(name)) { + throw new TypeError('Invalid character in header field name') + } + return name.toLowerCase() + } + + function normalizeValue(value) { + if (typeof value !== 'string') { + value = String(value) + } + return value + } + + // Build a destructive iterator for the value list + function iteratorFor(items) { + var iterator = { + next: function() { + var value = items.shift() + return {done: value === undefined, value: value} + } + } + + if (support.iterable) { + iterator[Symbol.iterator] = function() { + return iterator + } + } + + return iterator + } + + function Headers(headers) { + this.map = {} + + if (headers instanceof Headers) { + headers.forEach(function(value, name) { + this.append(name, value) + }, this) + } else if (Array.isArray(headers)) { + headers.forEach(function(header) { + this.append(header[0], header[1]) + }, this) + } else if (headers) { + Object.getOwnPropertyNames(headers).forEach(function(name) { + this.append(name, headers[name]) + }, this) + } + } + + Headers.prototype.append = function(name, value) { + name = normalizeName(name) + value = normalizeValue(value) + var oldValue = this.map[name] + this.map[name] = oldValue ? oldValue+','+value : value + } + + Headers.prototype['delete'] = function(name) { + delete this.map[normalizeName(name)] + } + + Headers.prototype.get = function(name) { + name = normalizeName(name) + return this.has(name) ? this.map[name] : null + } + + Headers.prototype.has = function(name) { + return this.map.hasOwnProperty(normalizeName(name)) + } + + Headers.prototype.set = function(name, value) { + this.map[normalizeName(name)] = normalizeValue(value) + } + + Headers.prototype.forEach = function(callback, thisArg) { + for (var name in this.map) { + if (this.map.hasOwnProperty(name)) { + callback.call(thisArg, this.map[name], name, this) + } + } + } + + Headers.prototype.keys = function() { + var items = [] + this.forEach(function(value, name) { items.push(name) }) + return iteratorFor(items) + } + + Headers.prototype.values = function() { + var items = [] + this.forEach(function(value) { items.push(value) }) + return iteratorFor(items) + } + + Headers.prototype.entries = function() { + var items = [] + this.forEach(function(value, name) { items.push([name, value]) }) + return iteratorFor(items) + } + + if (support.iterable) { + Headers.prototype[Symbol.iterator] = Headers.prototype.entries + } + + function consumed(body) { + if (body.bodyUsed) { + return Promise.reject(new TypeError('Already read')) + } + body.bodyUsed = true + } + + function fileReaderReady(reader) { + return new Promise(function(resolve, reject) { + reader.onload = function() { + resolve(reader.result) + } + reader.onerror = function() { + reject(reader.error) + } + }) + } + + function readBlobAsArrayBuffer(blob) { + var reader = new FileReader() + var promise = fileReaderReady(reader) + reader.readAsArrayBuffer(blob) + return promise + } + + function readBlobAsText(blob) { + var reader = new FileReader() + var promise = fileReaderReady(reader) + reader.readAsText(blob) + return promise + } + + function readArrayBufferAsText(buf) { + var view = new Uint8Array(buf) + var chars = new Array(view.length) + + for (var i = 0; i < view.length; i++) { + chars[i] = String.fromCharCode(view[i]) + } + return chars.join('') + } + + function bufferClone(buf) { + if (buf.slice) { + return buf.slice(0) + } else { + var view = new Uint8Array(buf.byteLength) + view.set(new Uint8Array(buf)) + return view.buffer + } + } + + function Body() { + this.bodyUsed = false + + this._initBody = function(body) { + this._bodyInit = body + if (!body) { + this._bodyText = '' + } else if (typeof body === 'string') { + this._bodyText = body + } else if (support.blob && Blob.prototype.isPrototypeOf(body)) { + this._bodyBlob = body + } else if (support.formData && FormData.prototype.isPrototypeOf(body)) { + this._bodyFormData = body + } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) { + this._bodyText = body.toString() + } else if (support.arrayBuffer && support.blob && isDataView(body)) { + this._bodyArrayBuffer = bufferClone(body.buffer) + // IE 10-11 can't handle a DataView body. + this._bodyInit = new Blob([this._bodyArrayBuffer]) + } else if (support.arrayBuffer && (ArrayBuffer.prototype.isPrototypeOf(body) || isArrayBufferView(body))) { + this._bodyArrayBuffer = bufferClone(body) + } else { + throw new Error('unsupported BodyInit type') + } + + if (!this.headers.get('content-type')) { + if (typeof body === 'string') { + this.headers.set('content-type', 'text/plain;charset=UTF-8') + } else if (this._bodyBlob && this._bodyBlob.type) { + this.headers.set('content-type', this._bodyBlob.type) + } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) { + this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8') + } + } + } + + if (support.blob) { + this.blob = function() { + var rejected = consumed(this) + if (rejected) { + return rejected + } + + if (this._bodyBlob) { + return Promise.resolve(this._bodyBlob) + } else if (this._bodyArrayBuffer) { + return Promise.resolve(new Blob([this._bodyArrayBuffer])) + } else if (this._bodyFormData) { + throw new Error('could not read FormData body as blob') + } else { + return Promise.resolve(new Blob([this._bodyText])) + } + } + + this.arrayBuffer = function() { + if (this._bodyArrayBuffer) { + return consumed(this) || Promise.resolve(this._bodyArrayBuffer) + } else { + return this.blob().then(readBlobAsArrayBuffer) + } + } + } + + this.text = function() { + var rejected = consumed(this) + if (rejected) { + return rejected + } + + if (this._bodyBlob) { + return readBlobAsText(this._bodyBlob) + } else if (this._bodyArrayBuffer) { + return Promise.resolve(readArrayBufferAsText(this._bodyArrayBuffer)) + } else if (this._bodyFormData) { + throw new Error('could not read FormData body as text') + } else { + return Promise.resolve(this._bodyText) + } + } + + if (support.formData) { + this.formData = function() { + return this.text().then(decode) + } + } + + this.json = function() { + return this.text().then(JSON.parse) + } + + return this + } + + // HTTP methods whose capitalization should be normalized + var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'] + + function normalizeMethod(method) { + var upcased = method.toUpperCase() + return (methods.indexOf(upcased) > -1) ? upcased : method + } + + function Request(input, options) { + options = options || {} + var body = options.body + + if (input instanceof Request) { + if (input.bodyUsed) { + throw new TypeError('Already read') + } + this.url = input.url + this.credentials = input.credentials + if (!options.headers) { + this.headers = new Headers(input.headers) + } + this.method = input.method + this.mode = input.mode + if (!body && input._bodyInit != null) { + body = input._bodyInit + input.bodyUsed = true + } + } else { + this.url = String(input) + } + + this.credentials = options.credentials || this.credentials || 'omit' + if (options.headers || !this.headers) { + this.headers = new Headers(options.headers) + } + this.method = normalizeMethod(options.method || this.method || 'GET') + this.mode = options.mode || this.mode || null + this.referrer = null + + if ((this.method === 'GET' || this.method === 'HEAD') && body) { + throw new TypeError('Body not allowed for GET or HEAD requests') + } + this._initBody(body) + } + + Request.prototype.clone = function() { + return new Request(this, { body: this._bodyInit }) + } + + function decode(body) { + var form = new FormData() + body.trim().split('&').forEach(function(bytes) { + if (bytes) { + var split = bytes.split('=') + var name = split.shift().replace(/\+/g, ' ') + var value = split.join('=').replace(/\+/g, ' ') + form.append(decodeURIComponent(name), decodeURIComponent(value)) + } + }) + return form + } + + function parseHeaders(rawHeaders) { + var headers = new Headers() + // Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space + // https://tools.ietf.org/html/rfc7230#section-3.2 + var preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, ' ') + preProcessedHeaders.split(/\r?\n/).forEach(function(line) { + var parts = line.split(':') + var key = parts.shift().trim() + if (key) { + var value = parts.join(':').trim() + headers.append(key, value) + } + }) + return headers + } + + Body.call(Request.prototype) + + function Response(bodyInit, options) { + if (!options) { + options = {} + } + + this.type = 'default' + this.status = options.status === undefined ? 200 : options.status + this.ok = this.status >= 200 && this.status < 300 + this.statusText = 'statusText' in options ? options.statusText : 'OK' + this.headers = new Headers(options.headers) + this.url = options.url || '' + this._initBody(bodyInit) + } + + Body.call(Response.prototype) + + Response.prototype.clone = function() { + return new Response(this._bodyInit, { + status: this.status, + statusText: this.statusText, + headers: new Headers(this.headers), + url: this.url + }) + } + + Response.error = function() { + var response = new Response(null, {status: 0, statusText: ''}) + response.type = 'error' + return response + } + + var redirectStatuses = [301, 302, 303, 307, 308] + + Response.redirect = function(url, status) { + if (redirectStatuses.indexOf(status) === -1) { + throw new RangeError('Invalid status code') + } + + return new Response(null, {status: status, headers: {location: url}}) + } + + self.Headers = Headers + self.Request = Request + self.Response = Response + + self.fetch = function(input, init) { + return new Promise(function(resolve, reject) { + var request = new Request(input, init) + var xhr = new XMLHttpRequest() + + xhr.onload = function() { + var options = { + status: xhr.status, + statusText: xhr.statusText, + headers: parseHeaders(xhr.getAllResponseHeaders() || '') + } + options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL') + var body = 'response' in xhr ? xhr.response : xhr.responseText + resolve(new Response(body, options)) + } + + xhr.onerror = function() { + reject(new TypeError('Network request failed')) + } + + xhr.ontimeout = function() { + reject(new TypeError('Network request failed')) + } + + xhr.open(request.method, request.url, true) + + if (request.credentials === 'include') { + xhr.withCredentials = true + } else if (request.credentials === 'omit') { + xhr.withCredentials = false + } + + if ('responseType' in xhr && support.blob) { + xhr.responseType = 'blob' + } + + request.headers.forEach(function(value, name) { + xhr.setRequestHeader(name, value) + }) + + xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit) + }) + } + self.fetch.polyfill = true +})(typeof self !== 'undefined' ? self : this); \ No newline at end of file diff --git a/frontend/polyfills/main.js b/frontend/polyfills/main.js new file mode 100644 index 000000000..7e4c554ea --- /dev/null +++ b/frontend/polyfills/main.js @@ -0,0 +1,2 @@ +import './fetch'; +import './url-search-params'; diff --git a/frontend/polyfills/url-search-params.js b/frontend/polyfills/url-search-params.js new file mode 100644 index 000000000..e38c12021 --- /dev/null +++ b/frontend/polyfills/url-search-params.js @@ -0,0 +1,348 @@ +(function(global) { + /** + * Polyfill URLSearchParams + * + * Inspired from : https://github.com/WebReflection/url-search-params/blob/master/src/url-search-params.js + */ + + var checkIfIteratorIsSupported = function() { + try { + return !!Symbol.iterator; + } catch(error) { + return false; + } + }; + + + var iteratorSupported = checkIfIteratorIsSupported(); + + var createIterator = function(items) { + var iterator = { + next: function() { + var value = items.shift(); + return { done: value === void 0, value: value }; + } + }; + + if(iteratorSupported) { + iterator[Symbol.iterator] = function() { + return iterator; + }; + } + + return iterator; + }; + + /** + * Search param name and values should be encoded according to https://url.spec.whatwg.org/#urlencoded-serializing + * encodeURIComponent() produces the same result except encoding spaces as `%20` instead of `+`. + */ + var serializeParam = function(value) { + return encodeURIComponent(value).replace(/%20/g, '+'); + }; + + var deserializeParam = function(value) { + return decodeURIComponent(value).replace(/\+/g, ' '); + }; + + var polyfillURLSearchParams= function() { + + var URLSearchParams = function(searchString) { + Object.defineProperty(this, '_entries', { value: {} }); + + if(typeof searchString === 'string') { + if(searchString !== '') { + searchString = searchString.replace(/^\?/, ''); + var attributes = searchString.split('&'); + var attribute; + for(var i = 0; i < attributes.length; i++) { + attribute = attributes[i].split('='); + this.append( + deserializeParam(attribute[0]), + (attribute.length > 1) ? deserializeParam(attribute[1]) : '' + ); + } + } + } else if(searchString instanceof URLSearchParams) { + var _this = this; + searchString.forEach(function(value, name) { + _this.append(value, name); + }); + } + }; + + var proto = URLSearchParams.prototype; + + proto.append = function(name, value) { + if(name in this._entries) { + this._entries[name].push(value.toString()); + } else { + this._entries[name] = [value.toString()]; + } + }; + + proto.delete = function(name) { + delete this._entries[name]; + }; + + proto.get = function(name) { + return (name in this._entries) ? this._entries[name][0] : null; + }; + + proto.getAll = function(name) { + return (name in this._entries) ? this._entries[name].slice(0) : []; + }; + + proto.has = function(name) { + return (name in this._entries); + }; + + proto.set = function(name, value) { + this._entries[name] = [value.toString()]; + }; + + proto.forEach = function(callback, thisArg) { + var entries; + for(var name in this._entries) { + if(this._entries.hasOwnProperty(name)) { + entries = this._entries[name]; + for(var i = 0; i < entries.length; i++) { + callback.call(thisArg, entries[i], name, this); + } + } + } + }; + + proto.keys = function() { + var items = []; + this.forEach(function(value, name) { items.push(name); }); + return createIterator(items); + }; + + proto.values = function() { + var items = []; + this.forEach(function(value) { items.push(value); }); + return createIterator(items); + }; + + proto.entries = function() { + var items = []; + this.forEach(function(value, name) { items.push([name, value]); }); + return createIterator(items); + }; + + if(iteratorSupported) { + proto[Symbol.iterator] = proto.entries; + } + + proto.toString = function() { + var searchString = ''; + this.forEach(function(value, name) { + if(searchString.length > 0) searchString+= '&'; + searchString += serializeParam(name) + '=' + serializeParam(value); + }); + return searchString; + }; + + global.URLSearchParams = URLSearchParams; + }; + + if(!('URLSearchParams' in global) || (new URLSearchParams('?a=1').toString() !== 'a=1')) { + polyfillURLSearchParams(); + } + + // HTMLAnchorElement + +})( + (typeof global !== 'undefined') ? global + : ((typeof window !== 'undefined') ? window + : ((typeof self !== 'undefined') ? self : this)) +); + +(function(global) { + /** + * Polyfill URL + * + * Inspired from : https://github.com/arv/DOM-URL-Polyfill/blob/master/src/url.js + */ + + var checkIfURLIsSupported = function() { + try { + var u = new URL('b', 'http://a'); + u.pathname = 'c%20d'; + return (u.href === 'http://a/c%20d') && u.searchParams; + } catch(e) { + return false; + } + }; + + + var polyfillURL = function() { + var _URL = global.URL; + + var URL = function(url, base) { + if(typeof url !== 'string') url = String(url); + + var doc = document.implementation.createHTMLDocument(''); + window.doc = doc; + if(base) { + var baseElement = doc.createElement('base'); + baseElement.href = base; + doc.head.appendChild(baseElement); + } + + var anchorElement = doc.createElement('a'); + anchorElement.href = url; + doc.body.appendChild(anchorElement); + anchorElement.href = anchorElement.href; // force href to refresh + + if(anchorElement.protocol === ':' || !/:/.test(anchorElement.href)) { + throw new TypeError('Invalid URL'); + } + + Object.defineProperty(this, '_anchorElement', { + value: anchorElement + }); + }; + + var proto = URL.prototype; + + var linkURLWithAnchorAttribute = function(attributeName) { + Object.defineProperty(proto, attributeName, { + get: function() { + return this._anchorElement[attributeName]; + }, + set: function(value) { + this._anchorElement[attributeName] = value; + }, + enumerable: true + }); + }; + + ['hash', 'host', 'hostname', 'port', 'protocol', 'search'] + .forEach(function(attributeName) { + linkURLWithAnchorAttribute(attributeName); + }); + + Object.defineProperties(proto, { + + 'toString': { + get: function() { + var _this = this; + return function() { + return _this.href; + }; + } + }, + + 'href' : { + get: function() { + return this._anchorElement.href.replace(/\?$/,''); + }, + set: function(value) { + this._anchorElement.href = value; + }, + enumerable: true + }, + + 'pathname' : { + get: function() { + return this._anchorElement.pathname.replace(/(^\/?)/,'/'); + }, + set: function(value) { + this._anchorElement.pathname = value; + }, + enumerable: true + }, + + 'origin': { + get: function() { + // get expected port from protocol + var expectedPort = {'http:': 80, 'https:': 443, 'ftp:': 21}[this._anchorElement.protocol]; + // add port to origin if, expected port is different than actual port + // and it is not empty f.e http://foo:8080 + // 8080 != 80 && 8080 != '' + var addPortToOrigin = this._anchorElement.port != expectedPort && + this._anchorElement.port !== '' + + return this._anchorElement.protocol + + '//' + + this._anchorElement.hostname + + (addPortToOrigin ? (':' + this._anchorElement.port) : ''); + }, + enumerable: true + }, + + 'password': { // TODO + get: function() { + return ''; + }, + set: function(value) { + }, + enumerable: true + }, + + 'username': { // TODO + get: function() { + return ''; + }, + set: function(value) { + }, + enumerable: true + }, + + 'searchParams': { + get: function() { + var searchParams = new URLSearchParams(this.search); + var _this = this; + ['append', 'delete', 'set'].forEach(function(methodName) { + var method = searchParams[methodName]; + searchParams[methodName] = function() { + method.apply(searchParams, arguments); + _this.search = searchParams.toString(); + }; + }); + return searchParams; + }, + enumerable: true + } + }); + + URL.createObjectURL = function(blob) { + return _URL.createObjectURL.apply(_URL, arguments); + }; + + URL.revokeObjectURL = function(url) { + return _URL.revokeObjectURL.apply(_URL, arguments); + }; + + global.URL = URL; + + }; + + if(!checkIfURLIsSupported()) { + polyfillURL(); + } + + if((global.location !== void 0) && !('origin' in global.location)) { + var getOrigin = function() { + return global.location.protocol + '//' + global.location.hostname + (global.location.port ? (':' + global.location.port) : ''); + }; + + try { + Object.defineProperty(global.location, 'origin', { + get: getOrigin, + enumerable: true + }); + } catch(e) { + setInterval(function() { + global.location.origin = getOrigin(); + }, 100); + } + } + +})( + (typeof global !== 'undefined') ? global + : ((typeof window !== 'undefined') ? window + : ((typeof self !== 'undefined') ? self : this)) +); diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 000000000..91ebb8e24 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,43 @@ +import { HttpClient } from './services/http-client/http-client'; +import { HtmlHelpers } from './services/html-helpers/html-helpers'; +import { I18n } from './services/i18n/i18n'; +import { UtilRegistry } from './services/util-registry/util-registry'; + +// import utils +import Utils from './utils/utils'; + +class App { + httpClient = new HttpClient(); + htmlHelpers = new HtmlHelpers(); + i18n = new I18n(); + utilRegistry = new UtilRegistry(); + + constructor() { + this.utilRegistry.setApp(this); + + Utils.forEach(util => { + this.utilRegistry.register(util); + }); + } +} + +export const app = new App(); + +// attach the app to window to be able to get a hold of the +// app instance from the shakespearean templates +window.App = app; + +// dont know where to put this currently... +// interceptor to throw an error if an http response does not match the expected content-type +// function contentTypeInterceptor(response, options) { +// if (!options || !options.headers.get('Accept')) { +// return; +// } + +// const contentType = response.headers.get("content-type"); +// if (!contentType.match(options.accept)) { +// throw new Error('Server returned with "' + contentType + '" when "' + options.accept + '" was expected'); +// } +// } + +// window.HttpClient.addResponseInterceptor(contentTypeInterceptor); diff --git a/frontend/src/services/html-helpers/html-helpers.js b/frontend/src/services/html-helpers/html-helpers.js new file mode 100644 index 000000000..7956a569c --- /dev/null +++ b/frontend/src/services/html-helpers/html-helpers.js @@ -0,0 +1,41 @@ +export class HtmlHelpers { + + // `parseResponse` takes a raw HttpClient response and an options object. + // Returns an object with `element` being an contextual fragment of the + // HTML in the response and `ifPrefix` being the prefix that was used to + // "unique-ify" the ids of the received HTML. + // Original Response IDs can optionally be kept by adding `keepIds: true` + // to the `options` object. + parseResponse(response, options = {}) { + return response.text() + .then( + (responseText) => { + const docFrag = document.createRange().createContextualFragment(responseText); + let idPrefix; + if (!options.keepIds) { + idPrefix = this._getIdPrefix(); + this._prefixIds(docFrag, idPrefix); + } + return Promise.resolve({ idPrefix, element: docFrag }); + }, + Promise.reject, + ).catch(console.error); + } + + _prefixIds(element, idPrefix) { + const idAttrs = ['id', 'for', 'data-conditional-input', 'data-modal-trigger']; + + idAttrs.forEach((attr) => { + Array.from(element.querySelectorAll('[' + attr + ']')).forEach((input) => { + const value = idPrefix + input.getAttribute(attr); + input.setAttribute(attr, value); + }); + }); + } + + _getIdPrefix() { + // leading 'r'(andom) to overcome the fact that IDs + // starting with a numeric value are not valid in CSS + return 'r' + Math.floor(Math.random() * 100000) + '__'; + } +} diff --git a/frontend/src/services/http-client/http-client.js b/frontend/src/services/http-client/http-client.js new file mode 100644 index 000000000..6ba2341f0 --- /dev/null +++ b/frontend/src/services/http-client/http-client.js @@ -0,0 +1,41 @@ +export class HttpClient { + + static ACCEPT = { + TEXT_HTML: 'text/html', + JSON: 'application/json', + }; + + _responseInterceptors = []; + + addResponseInterceptor(interceptor) { + if (typeof interceptor === 'function') { + this._responseInterceptors.push(interceptor); + } + } + + get(args) { + args.method = 'GET'; + return this._fetch(args); + } + + post(args) { + args.method = 'POST'; + return this._fetch(args); + } + + _fetch(options) { + const requestOptions = { + credentials: 'same-origin', + ...options, + }; + + return fetch(options.url, requestOptions) + .then( + (response) => { + this._responseInterceptors.forEach((interceptor) => interceptor(response, options)); + return Promise.resolve(response); + }, + Promise.reject, + ).catch(console.error); + } +} diff --git a/frontend/src/services/i18n/i18n.js b/frontend/src/services/i18n/i18n.js new file mode 100644 index 000000000..7061f6ba6 --- /dev/null +++ b/frontend/src/services/i18n/i18n.js @@ -0,0 +1,32 @@ + /** + * I18n + * + * This module stores and serves translated strings, according to the users language settings. + * + * Translations are stored in /messages/frontend/*.msg. + * + * To make additions to any of these files accessible to JavaScrip Utilities + * you need to add them to the respective *.msg file and to the list of FrontendMessages + * in /src/Utils/Frontend/I18n.hs. + * + */ + +export class I18n { + + translations = {}; + + add(id, translation) { + this.translations[id] = translation; + } + + addMany(manyTranslations) { + Object.keys(manyTranslations).forEach((key) => this.add(key, manyTranslations[key])); + } + + get(id) { + if (!this.translations[id]) { + throw new Error('I18N Error: Translation missing for »' + id + '«!'); + } + return this.translations[id]; + } +} diff --git a/frontend/src/services/util-registry/util-registry.js b/frontend/src/services/util-registry/util-registry.js new file mode 100644 index 000000000..001bb1e6b --- /dev/null +++ b/frontend/src/services/util-registry/util-registry.js @@ -0,0 +1,122 @@ +const DEBUG_MODE = /localhost/.test(window.location.href) && 0; + +export class UtilRegistry { + + _registeredUtils = []; + _activeUtilInstances = []; + _appInstance; + + constructor() { + document.addEventListener('DOMContentLoaded', () => this.setupAll()); + } + + /** + * function registerUtil + * + * utils need to have at least these properties: + * name: string | utils name, e.g. 'example' + * selector: string | utils selector, e.g. '[uw-example]' + * setup: Function | utils setup function, see below + * + * setup function must return instance object with at least these properties: + * name: string | utils name + * element: HTMLElement | element the util is applied to + * destroy: Function | function to destroy the util and remove any listeners + * + * @param util Object Utility that should be added to the registry + */ + register(util) { + if (DEBUG_MODE > 2) { + console.log('registering util "' + util.name + '"'); + console.log({ util }); + } + this._registeredUtils.push(util); + } + + deregister(name, destroy) { + const utilIndex = this._findUtilIndex(name); + + if (utilIndex >= 0) { + if (destroy === true) { + this._destroyUtilInstances(name); + } + + this._registeredUtils.splice(utilIndex, 1); + } + } + + setApp(appInstance) { + this.appInstance = appInstance; + } + + setupAll = (scope) => { + if (DEBUG_MODE > 1) { + console.info('registered js utilities:'); + console.table(this._registeredUtils); + } + + this._registeredUtils.forEach((util) => this.setup(util, scope)); + } + + setup(util, scope = document.body) { + if (DEBUG_MODE > 2) { + console.log('setting up util', { util }); + } + + if (util && typeof util.setup === 'function') { + const elements = this._findUtilElements(util, scope); + + elements.forEach((element) => { + let utilInstance = null; + + try { + utilInstance = util.setup(element, this.appInstance); + } catch(err) { + if (DEBUG_MODE > 0) { + console.warn('Error while trying to initialize a utility!', { util , element, err }); + } + } + + if (utilInstance) { + if (DEBUG_MODE > 2) { + console.info('Got utility instance for utility "' + util.name + '"', { utilInstance }); + } + + this._activeUtilInstances.push(utilInstance); + } + }); + } + } + + find(name) { + return this._registeredUtils.find((util) => util.name === name); + } + + _findUtilElements(util, scope) { + if (scope && scope.matches(util.selector)) { + return [scope]; + } + return Array.from(scope.querySelectorAll(util.selector)); + } + + _findUtilIndex(name) { + return this._registeredUtils.findIndex((util) => util.name === name); + } + + _destroyUtilInstances(name) { + this._activeUtilInstances + .map((util, index) => ({ + util: util, + index: index, + })) + .filter((activeUtil) => activeUtil.util.name === name) + .forEach((activeUtil) => { + // destroy util instance + activeUtil.util.destroy(); + delete this._activeUtilInstances[activeUtil.index]; + }); + + // get rid of now empty array slots + this._activeUtilInstances = this._activeUtilInstances.filter((util) => !!util); + } +} diff --git a/frontend/src/utils/alerts/alerts.js b/frontend/src/utils/alerts/alerts.js new file mode 100644 index 000000000..3e49080bc --- /dev/null +++ b/frontend/src/utils/alerts/alerts.js @@ -0,0 +1,206 @@ +import './alerts.scss'; + +/** + * + * Alerts Utility + * makes alerts interactive + * + * Attribute: uw-alerts + * + * Types of alerts: + * [default] + * Regular Info Alert + * Disappears automatically after 30 seconds + * Disappears after x seconds if explicitly specified via data-decay='x' + * Can be told not to disappear with data-decay='0' + * + * [success] + * Currently no special visual appearance + * Disappears automatically after 30 seconds + * + * [warning] + * Will be coloured warning-orange regardless of user's selected theme + * Does not disappear + * + * [error] + * Will be coloured error-red regardless of user's selected theme + * Does not disappear + * + * Example usage: + *
+ *
+ *
+ *
+ *
+ *
+ * This is some information + * + */ + +var ALERTS_UTIL_NAME = 'alerts'; +var ALERTS_UTIL_SELECTOR = '[uw-alerts]'; + +var ALERTS_INITIALIZED_CLASS = 'alerts--initialized'; +var ALERTS_ELEVATED_CLASS = 'alerts--elevated'; +var ALERTS_TOGGLER_CLASS = 'alerts__toggler'; +var ALERTS_TOGGLER_VISIBLE_CLASS = 'alerts__toggler--visible'; +var ALERTS_TOGGLER_APPEAR_DELAY = 120; + +var ALERT_CLASS = 'alert'; +var ALERT_INITIALIZED_CLASS = 'alert--initialized'; +var ALERT_CLOSER_CLASS = 'alert__closer'; +var ALERT_ICON_CLASS = 'alert__icon'; +var ALERT_CONTENT_CLASS = 'alert__content'; +var ALERT_INVISIBLE_CLASS = 'alert--invisible'; +var ALERT_AUTO_HIDE_DELAY = 10; +var ALERT_AUTOCLOSING_MATCHER = '.alert-info, .alert-success'; + +var alertsUtil = function(element, app) { + var togglerCheckRequested = false; + var togglerElement; + var alertElements; + + function init() { + if (!element) { + throw new Error('Alerts util has to be called with an element!'); + } + + if (element.classList.contains(ALERTS_INITIALIZED_CLASS)) { + return false; + } + + togglerElement = element.querySelector('.' + ALERTS_TOGGLER_CLASS); + alertElements = gatherAlertElements(); + + initToggler(); + initAlerts(); + + // register http client interceptor to filter out Alerts Header + setupHttpInterceptor(); + + // mark initialized + element.classList.add(ALERTS_INITIALIZED_CLASS); + + return { + name: ALERTS_UTIL_NAME, + element: element, + destroy: function() {}, + }; + } + + function gatherAlertElements() { + return Array.from(element.querySelectorAll('.' + ALERT_CLASS)).filter(function(alert) { + return !alert.classList.contains(ALERT_INITIALIZED_CLASS); + }); + } + + function initToggler() { + togglerElement.addEventListener('click', function() { + alertElements.forEach(function(alertEl) { + toggleAlert(alertEl, true); + }); + togglerElement.classList.remove(ALERTS_TOGGLER_VISIBLE_CLASS); + }); + } + + function initAlerts() { + alertElements.forEach(initAlert); + } + + function initAlert(alertElement) { + var autoHideDelay = ALERT_AUTO_HIDE_DELAY; + if (alertElement.dataset.decay) { + autoHideDelay = parseInt(alertElement.dataset.decay, 10); + } + + var closeEl = alertElement.querySelector('.' + ALERT_CLOSER_CLASS); + closeEl.addEventListener('click', function() { + toggleAlert(alertElement); + }); + + if (autoHideDelay > 0 && alertElement.matches(ALERT_AUTOCLOSING_MATCHER)) { + window.setTimeout(function() { + toggleAlert(alertElement); + }, autoHideDelay * 1000); + } + } + + function toggleAlert(alertEl, visible) { + alertEl.classList.toggle(ALERT_INVISIBLE_CLASS, !visible); + checkToggler(); + } + + function checkToggler() { + if (togglerCheckRequested) { + return; + } + + var alertsHidden = alertElements.reduce(function(acc, alert) { + return acc && alert.classList.contains(ALERT_INVISIBLE_CLASS); + }, true); + + window.setTimeout(function() { + togglerElement.classList.toggle(ALERTS_TOGGLER_VISIBLE_CLASS, alertsHidden); + togglerCheckRequested = false; + }, ALERTS_TOGGLER_APPEAR_DELAY); + } + + function setupHttpInterceptor() { + app.httpClient.addResponseInterceptor(responseInterceptor.bind(this)); + } + + function elevateAlerts() { + element.classList.add(ALERTS_ELEVATED_CLASS); + } + + function responseInterceptor(response) { + var alerts; + for (var header of response.headers) { + if (header[0] === 'alerts') { + var decodedHeader = decodeURIComponent(header[1]); + alerts = JSON.parse(decodedHeader); + break; + } + } + + if (alerts) { + alerts.forEach(function(alert) { + var alertElement = createAlertElement(alert.status, alert.content); + element.appendChild(alertElement); + alertElements.push(alertElement); + initAlert(alertElement); + }); + + elevateAlerts(); + } + } + + function createAlertElement(type, content) { + var alertElement = document.createElement('div'); + alertElement.classList.add(ALERT_CLASS, 'alert-' + type); + + var alertCloser = document.createElement('div'); + alertCloser.classList.add(ALERT_CLOSER_CLASS); + + var alertIcon = document.createElement('div'); + alertIcon.classList.add(ALERT_ICON_CLASS); + + var alertContent = document.createElement('div'); + alertContent.classList.add(ALERT_CONTENT_CLASS); + alertContent.innerHTML = content; + + alertElement.appendChild(alertCloser); + alertElement.appendChild(alertIcon); + alertElement.appendChild(alertContent); + + return alertElement; + } + + return init(); +}; + +export default { + name: ALERTS_UTIL_NAME, + selector: ALERTS_UTIL_SELECTOR, + setup: alertsUtil, +}; diff --git a/frontend/src/utils/alerts/alerts.scss b/frontend/src/utils/alerts/alerts.scss new file mode 100644 index 000000000..cd0492f11 --- /dev/null +++ b/frontend/src/utils/alerts/alerts.scss @@ -0,0 +1,210 @@ +.alerts { + position: fixed; + bottom: 0; + right: 5%; + z-index: 20; + text-align: right; + display: flex; + flex-direction: column; +} + +.alerts__toggler { + width: 40px; + height: 40px; + position: absolute; + top: 400px; + left: 50%; + transform: translateX(-50%); + cursor: pointer; + + &::before { + content: '\f077'; + position: absolute; + font-family: "Font Awesome 5 Free"; + left: 50%; + top: 0; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + width: 30px; + color: var(--color-grey); + font-size: 30px; + transform: translateX(-50%); + } +} + +.alerts--elevated { + z-index: 1000; +} + +.alerts__toggler--visible { + top: -40px; + opacity: 1; + transition: top .5s cubic-bezier(0.73, 1.25, 0.61, 1), + opacity .5s cubic-bezier(0.73, 1.25, 0.61, 1); +} + +@media (max-width: 425px) { + + .alerts { + left: 5%; + } +} + +.alert { + position: relative; + display: block; + background-color: var(--color-lightblack); + font-size: 1rem; + color: var(--color-lightwhite); + z-index: 0; + padding: 0 50px; + padding-right: 60px; + animation: slide-in-alert .2s ease-out forwards; + margin-bottom: 10px; + transition: margin-bottom .2s ease-out; +} + +.alert a { + color: var(--color-lightwhite); +} + +@keyframes slide-in-alert { + from { + transform: translateY(120%); + } + to { + transform: translateY(0); + } +} + +@keyframes slide-out-alert { + from { + transform: translateY(0); + max-height: 200px; + } + to { + transform: translateY(250%); + opacity: 0; + max-height: 0; + overflow: hidden; + } +} + +@media (min-width: 425px) { + + .alert { + max-width: 400px; + } +} + +.alert--invisible { + animation: slide-out-alert .2s ease-out forwards; + margin-bottom: 0; +} + +.alert__content { + padding: 8px 0; + min-height: 40px; + position: relative; + display: flex; + font-weight: 600; + align-items: center; + text-align: left; +} + +.alert__icon { + text-align: right; + position: absolute; + left: 0px; + top: 0; + width: 50px; + height: 100%; + z-index: 40; + + &::before { + content: '\f05a'; + position: absolute; + font-family: "Font Awesome 5 Free"; + font-size: 24px; + top: 50%; + left: 50%; + display: flex; + align-items: center; + justify-content: center; + transform: translate(-50%, -50%); + border-radius: 50%; + width: 30px; + height: 30px; + } +} + +.alert__closer { + cursor: pointer; + text-align: right; + position: absolute; + right: 0px; + top: 0; + width: 60px; + height: 100%; + transition: all .3s ease; + z-index: 40; + + &:hover { + transform: scale(1.05, 1.05); + + &::before { + box-shadow: 0 0 4px white; + background-color: rgba(255, 255, 255, 0.1); + color: white; + } + } + + &::before { + content: '\f00d'; + position: absolute; + font-family: "Font Awesome 5 Free"; + top: 50%; + left: 50%; + display: flex; + align-items: center; + justify-content: center; + transform: translate(-50%, -50%); + border-radius: 50%; + width: 30px; + height: 30px; + transition: all .15s ease; + } +} + +@media (max-width: 768px) { + + .alert__closer { + width: 40px; + } +} + +.alert-success { + background-color: var(--color-success); + + .alert__icon::before { + content: '\f058'; + } +} + +.alert-warning { + background-color: var(--color-warning); + + .alert__icon::before { + content: '\f06a'; + } +} + +.alert-error { + background-color: var(--color-error); + + .alert__icon::before { + content: '\f071'; + } +} diff --git a/frontend/src/utils/asidenav/asidenav.js b/frontend/src/utils/asidenav/asidenav.js new file mode 100644 index 000000000..c5ba552c9 --- /dev/null +++ b/frontend/src/utils/asidenav/asidenav.js @@ -0,0 +1,105 @@ +import './asidenav.scss'; + +/** + * + * Asidenav Utility + * Correctly positions hovered asidenav submenus and handles the favorites button on mobile + * + * Attribute: uw-asidenav + * + * Example usage: + *
+ *
+ *
+ *