diff --git a/packages/app-shell/.eslintignore b/packages/app-shell/.eslintignore new file mode 100644 index 0000000000000000000000000000000000000000..7b596da7b5a30a2b742e9bc9bc8002606940e18a --- /dev/null +++ b/packages/app-shell/.eslintignore @@ -0,0 +1,3 @@ +/vendor/** +/dist/** +/*.js \ No newline at end of file diff --git a/packages/app-shell/.eslintrc.json b/packages/app-shell/.eslintrc.json new file mode 100644 index 0000000000000000000000000000000000000000..1ccd30a3fee0b4867a0172e746714eb0083fb07d --- /dev/null +++ b/packages/app-shell/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "env": { + "browser": true, + "es6": true, + "mocha": true + }, + "extends": ["eslint:recommended", "plugin:jsdoc/recommended"], + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parser": "babel-eslint", + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module" + }, + "rules": { + "no-unused-vars": ["error", { "args": "none" }], + "semi": [2, "always"], + "jsdoc/require-jsdoc": 0, + "jsdoc/require-param-description": 0, + "jsdoc/require-returns": 0, + "jsdoc/require-param-type": 0 + } +} \ No newline at end of file diff --git a/packages/app-shell/.gitignore b/packages/app-shell/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..f40e160191d6a30e5686b3dbff20badfb69c85bd --- /dev/null +++ b/packages/app-shell/.gitignore @@ -0,0 +1,7 @@ +dist +node_modules +.idea +npm-debug.log +.vscode +.cert +package-lock.json \ No newline at end of file diff --git a/packages/app-shell/.gitlab-ci.yml b/packages/app-shell/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..891533dd3e428e5f8958b60a803a64fb6cf05e03 --- /dev/null +++ b/packages/app-shell/.gitlab-ci.yml @@ -0,0 +1,23 @@ +image: registry.gitlab.tugraz.at/vpu/webcomponents/common/main:v5 + +before_script: + - "sed -i 's|git@gitlab.tugraz.at:VPU|../..|g' .gitmodules" + - git submodule sync + - git submodule update --init + +stages: + - test + +test: + stage: test + script: + - npm install + - npm run build + - npm test + +linting: + stage: test + allow_failure: true + script: + - npm install + - npm run lint \ No newline at end of file diff --git a/packages/app-shell/.gitmodules b/packages/app-shell/.gitmodules new file mode 100644 index 0000000000000000000000000000000000000000..94f3fdc5c27aa61f38566c5d449ebb1a08ac4d91 --- /dev/null +++ b/packages/app-shell/.gitmodules @@ -0,0 +1,15 @@ +[submodule "vendor/auth"] + path = vendor/auth + url = git@gitlab.tugraz.at:VPU/WebComponents/Auth.git +[submodule "vendor/common"] + path = vendor/common + url = git@gitlab.tugraz.at:VPU/WebComponents/Common +[submodule "vendor/notification"] + path = vendor/notification + url = git@gitlab.tugraz.at:VPU/WebComponents/Notification +[submodule "vendor/language-select"] + path = vendor/language-select + url = git@gitlab.tugraz.at:VPU/WebComponents/LanguageSelect +[submodule "vendor/person-profile"] + path = vendor/person-profile + url = git@gitlab.tugraz.at:VPU/WebComponents/PersonProfile diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/packages/app-shell/assets/example.metadata.json b/packages/app-shell/assets/example.metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..1bf5ace2d013da6ad0bb1b78b0d8a609642b7fbb --- /dev/null +++ b/packages/app-shell/assets/example.metadata.json @@ -0,0 +1,17 @@ +{ + "element": "vpu-activity-example", + "module_src": "vpu-activity-example.js", + "routing_name": "activity-example", + "name": { + "de": "Beispielaktivität", + "en": "Example Activity" + }, + "short_name": { + "de": "Beispielaktivität", + "en": "Example Activity" + }, + "description": { + "de": "Eine Beschreibung", + "en": "A Description" + } +} diff --git a/packages/app-shell/assets/example.topic.metadata.json b/packages/app-shell/assets/example.topic.metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..6ccda29d5072e2dd9ba5ccdd0a54aa3eacd60e02 --- /dev/null +++ b/packages/app-shell/assets/example.topic.metadata.json @@ -0,0 +1,19 @@ +{ + "name": { + "de": "Beispiel", + "en": "Example" + }, + "short_name": { + "de": "Beispiel", + "en": "Example" + }, + "description": { + "de": "", + "en": "" + }, + "routing_name": "example", + "activities": [ + {"path": "example.metadata.json"} + ], + "attributes": [] +} \ No newline at end of file diff --git a/packages/app-shell/assets/index.html b/packages/app-shell/assets/index.html new file mode 100644 index 0000000000000000000000000000000000000000..25bfd720d53abfe49b49262fcd670baa357423f7 --- /dev/null +++ b/packages/app-shell/assets/index.html @@ -0,0 +1,21 @@ +<!doctype html> +<html> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + + <style> + :root { + font-family: sans-serif; + } + </style> + + <script type="module" src="/demo.js"></script> +</head> + +<body> + +<vpu-app lang="de" src="/example.topic.metadata.json" base-path="/"></vpu-app> + +</body> +</html> \ No newline at end of file diff --git a/packages/app-shell/assets/silent-check-sso.html b/packages/app-shell/assets/silent-check-sso.html new file mode 100644 index 0000000000000000000000000000000000000000..94fe2268cbd7ff3f752f2093bb3cddd6d10b6388 --- /dev/null +++ b/packages/app-shell/assets/silent-check-sso.html @@ -0,0 +1,7 @@ +<html> +<body> + <script> + parent.postMessage(location.href, location.origin) + </script> +</body> +</html> \ No newline at end of file diff --git a/packages/app-shell/karma.conf.js b/packages/app-shell/karma.conf.js new file mode 100644 index 0000000000000000000000000000000000000000..f9cda6ad8cd84ec117f224be7da7c534b48b0490 --- /dev/null +++ b/packages/app-shell/karma.conf.js @@ -0,0 +1,28 @@ +// Trick to use the auto-downloaded puppeteer chrome binary +process.env.CHROME_BIN = require('puppeteer').executablePath(); + +module.exports = function(config) { + config.set({ + basePath: 'dist', + frameworks: ['mocha'], + client: { + mocha: { + ui: 'tdd', + }, + }, + files: [ + {pattern: './*.js', included: true, watched: true, served: true, type: 'module'}, + {pattern: './**/*', included: false, watched: true, served: true}, + ], + autoWatch: true, + browsers: ['ChromeHeadlessNoSandbox', 'FirefoxHeadless'], + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: 'ChromeHeadless', + flags: ['--no-sandbox'] + } + }, + singleRun: false, + logLevel: config.LOG_ERROR + }); +} diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json new file mode 100644 index 0000000000000000000000000000000000000000..4629dc8e59f3e7df3769803a2d715f2717497c1e --- /dev/null +++ b/packages/app-shell/package.json @@ -0,0 +1,46 @@ +{ + "name": "vpu-app-shell", + "version": "1.0.0", + "main": "src/index.js", + "devDependencies": { + "@rollup/plugin-commonjs": "^11.0.2", + "@rollup/plugin-json": "^4.0.2", + "@rollup/plugin-node-resolve": "^7.1.1", + "babel-eslint": "^10.1.0", + "chai": "^4.2.0", + "eslint": "^6.8.0", + "eslint-plugin-jsdoc": "^22.1.0", + "glob": "^7.1.6", + "karma": "^4.4.1", + "karma-chrome-launcher": "^3.1.0", + "karma-firefox-launcher": "^1.3.0", + "karma-mocha": "^1.3.0", + "mocha": "^7.1.1", + "puppeteer": "^2.1.1", + "rollup": "^2.0.0", + "rollup-plugin-consts": "^1.0.1", + "rollup-plugin-copy": "^3.3.0", + "rollup-plugin-delete": "^1.2.0", + "rollup-plugin-serve": "^1.0.1" + }, + "dependencies": { + "i18next": "^19.1.0", + "lit-element": "^2.2.1", + "lit-html": "^1.1.2", + "universal-router": "^8.3.0", + "vpu-auth": "file:./vendor/auth", + "vpu-common": "file:./vendor/common", + "vpu-language-select": "file:./vendor/language-select", + "vpu-notification": "file:./vendor/notification", + "vpu-person-profile": "file:./vendor/person-profile" + }, + "scripts": { + "build": "npm run build-local", + "build-local": "rollup -c", + "build-test": "rollup -c --environment BUILD:test", + "watch": "npm run watch-local", + "watch-local": "rollup -c --watch", + "test": "npm run build-test && karma start --singleRun", + "lint": "eslint ." + } +} diff --git a/packages/app-shell/rollup.config.js b/packages/app-shell/rollup.config.js new file mode 100644 index 0000000000000000000000000000000000000000..383f1c4cc0b4307d8a823811f4e631d19a101fed --- /dev/null +++ b/packages/app-shell/rollup.config.js @@ -0,0 +1,89 @@ +import path from 'path'; +import glob from 'glob'; +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import copy from 'rollup-plugin-copy'; +import serve from 'rollup-plugin-serve'; +import consts from 'rollup-plugin-consts'; +import del from 'rollup-plugin-delete'; +import json from '@rollup/plugin-json'; +import chai from 'chai'; + +const pkg = require('./package.json'); +const build = (typeof process.env.BUILD !== 'undefined') ? process.env.BUILD : 'local'; +console.log("build: " + build); + +function getBuildInfo() { + const child_process = require('child_process'); + const url = require('url'); + + let remote = child_process.execSync('git config --get remote.origin.url').toString().trim(); + let commit = child_process.execSync('git rev-parse --short HEAD').toString().trim(); + + let parsed = url.parse(remote); + let newPath = parsed.path.slice(0, parsed.path.lastIndexOf('.')); + let newUrl = parsed.protocol + '//' + parsed.host + newPath + '/commit/' + commit; + + return { + info: commit, + url: newUrl, + time: new Date().toISOString(), + env: build + } +} + +export default { + input: (build !='test') ? ['src/demo.js', 'src/vpu-activity-example.js'] : glob.sync('test/**/*.js'), + output: { + dir: 'dist', + entryFileNames: '[name].js', + chunkFileNames: 'shared/[name].[hash].[format].js', + format: 'esm', + sourcemap: true + }, + onwarn: function (warning, warn) { + // ignore chai warnings + if (warning.code === 'CIRCULAR_DEPENDENCY') { + return; + } + // keycloak bundled code uses eval + if (warning.code === 'EVAL') { + return; + } + warn(warning); + }, + plugins: [ + del({ + targets: 'dist/*' + }), + consts({ + environment: build, + buildinfo: getBuildInfo(), + }), + resolve({ + customResolveOptions: { + // ignore node_modules from vendored packages + moduleDirectory: path.join(process.cwd(), 'node_modules') + } + }), + commonjs({ + include: 'node_modules/**', + namedExports: { + 'chai': Object.keys(chai), + } + }), + json(), + copy({ + targets: [ + {src: 'assets/silent-check-sso.html', dest:'dist'}, + {src: 'assets/index.html', dest: 'dist'}, + {src: 'assets/*.json', dest: 'dist'}, + {src: 'node_modules/vpu-common/assets/icons/*.svg', dest: 'dist/local/vpu-common/icons'}, + ], + }), + (process.env.ROLLUP_WATCH === 'true') ? serve({ + contentBase: 'dist', + historyApiFallback: '/index.html', + host: '127.0.0.1', port: 8002}) : false + ] +}; diff --git a/packages/app-shell/src/build-info.js b/packages/app-shell/src/build-info.js new file mode 100644 index 0000000000000000000000000000000000000000..6be385b6538a539de5ebe711de3c165676bfd4e4 --- /dev/null +++ b/packages/app-shell/src/build-info.js @@ -0,0 +1,38 @@ +import {html, LitElement, css} from 'lit-element'; +import * as commonUtils from 'vpu-common/utils'; +import * as commonStyles from 'vpu-common/styles'; +import buildinfo from 'consts:buildinfo'; + +class VPUBuildInfo extends LitElement { + + constructor() { + super(); + } + + static get styles() { + return css` + ${commonStyles.getThemeCSS()} + ${commonStyles.getGeneralCSS()} + ${commonStyles.getTagCSS()} + + :host { + display: inline-block; + } + `; + } + + render() { + const date = new Date(buildinfo.time); + + return html` + <a href="${buildinfo.url}" style="float: right"> + <div class="tags has-addons" title="Build Time: ${date.toString()}"> + <span class="tag is-light">build</span> + <span class="tag is-dark">${buildinfo.info} (${buildinfo.env})</span> + </div> + </a> + `; + } +} + +commonUtils.defineCustomElement('vpu-build-info', VPUBuildInfo); diff --git a/packages/app-shell/src/demo.js b/packages/app-shell/src/demo.js new file mode 100644 index 0000000000000000000000000000000000000000..7a1d051c8191c053946d2ecace0e6e9e4258210e --- /dev/null +++ b/packages/app-shell/src/demo.js @@ -0,0 +1 @@ +import './index.js'; \ No newline at end of file diff --git a/packages/app-shell/src/i18n.js b/packages/app-shell/src/i18n.js new file mode 100644 index 0000000000000000000000000000000000000000..51af2ff597c8d4b8e1a448369ccecde23b47f04f --- /dev/null +++ b/packages/app-shell/src/i18n.js @@ -0,0 +1,20 @@ +import {createInstance} from 'vpu-common/i18next.js'; + +import de from './i18n/de/translation.json'; +import en from './i18n/en/translation.json'; + +const i18n = createInstance({en: en, de: de}, 'de', 'en'); + +export function createI18nInstance () { + return i18n.cloneInstance(); +} + +/** + * Dummy function to mark strings as i18next keys for i18next-scanner + * + * @param {string} key + * @returns {string} The key param as is + */ +export function i18nKey(key) { + return key; +} \ No newline at end of file diff --git a/packages/app-shell/src/i18n/de/translation.json b/packages/app-shell/src/i18n/de/translation.json new file mode 100644 index 0000000000000000000000000000000000000000..798379d699aba4a39551aab7e9df643aacdaef53 --- /dev/null +++ b/packages/app-shell/src/i18n/de/translation.json @@ -0,0 +1,12 @@ +{ + "logo": { + "word1": "Wissen", + "word2": "Technik", + "word3": "Leidenschaft" + }, + "privacy-policy": "Datenschutz", + "page-updated-needs-reload": "Die Applikation wurde aktualisiert. Bitte laden Sie die Seite neu.", + "activity-example": { + "hello-world": "Hallo Welt" + } +} diff --git a/packages/app-shell/src/i18n/en/translation.json b/packages/app-shell/src/i18n/en/translation.json new file mode 100644 index 0000000000000000000000000000000000000000..173eb1346b0ed059a1f427bbc7a3036665f56a22 --- /dev/null +++ b/packages/app-shell/src/i18n/en/translation.json @@ -0,0 +1,12 @@ +{ + "logo": { + "word1": "Science", + "word2": "Passion", + "word3": "Technology" + }, + "privacy-policy": "Privacy Policy", + "page-updated-needs-reload": "The application has been updated. Please reload the page.", + "activity-example": { + "hello-world": "Hello World" + } +} diff --git a/packages/app-shell/src/index.js b/packages/app-shell/src/index.js new file mode 100644 index 0000000000000000000000000000000000000000..6de6fbe17e266d58881114160b9e10e8f7c7ccef --- /dev/null +++ b/packages/app-shell/src/index.js @@ -0,0 +1,698 @@ +import {createI18nInstance} from './i18n.js'; +import {html, css, LitElement} from 'lit-element'; +import 'vpu-language-select'; +import 'vpu-common/vpu-button.js'; +import 'vpu-auth'; +import 'vpu-notification'; +import * as commonUtils from 'vpu-common/utils'; +import * as commonStyles from 'vpu-common/styles'; +import buildinfo from 'consts:buildinfo'; +import {classMap} from 'lit-html/directives/class-map.js'; +import {Router} from './router.js'; +import * as events from 'vpu-common/events.js'; +import './build-info.js'; +import './tugraz-logo.js'; +import {send as notify} from 'vpu-notification'; + + +const i18n = createI18nInstance(); + +/** + * In case the application gets updated future dynamic imports might fail. + * This sends a notification suggesting the user to reload the page. + * + * uage: importNotify(import('<path>')); + * + * @param {Promise} promise + */ +const importNotify = async (promise) => { + try { + return await promise; + } catch (error) { + console.log(error); + notify({ + "body": i18n.t('page-updated-needs-reload'), + "type": "info", + "icon": "warning" + }); + throw error; + } +}; + + +class VPUApp extends LitElement { + constructor() { + super(); + this.lang = i18n.language; + this.activeView = ''; + this.entryPointUrl = commonUtils.getAPiUrl(); + this.subtitle = ''; + this.description = ''; + this.routes = []; + this.metadata = {}; + this.topic = {}; + this.basePath = ''; + + this._updateAuth = this._updateAuth.bind(this); + this._loginStatus = 'unknown'; + this._subscriber = new events.EventSubscriber('vpu-auth-update', 'vpu-auth-update-request'); + + this._attrObserver = new MutationObserver(this.onAttributeObserved); + } + + onAttributeObserved(mutationsList, observer) { + for(let mutation of mutationsList) { + if (mutation.type === 'attributes') { + const key = mutation.attributeName; + const value = mutation.target.getAttribute(key); + sessionStorage.setItem('vpu-attr-' + key, value); + } + } + } + + /** + * Fetches the metadata of the components we want to use in the menu, dynamically imports the js modules for them, + * then triggers a rebuilding of the menu and resolves the current route + * + * @param {string} topicURL The topic metadata URL or relative path to load things from + */ + async fetchMetadata(topicURL) { + const metadata = {}; + const routes = []; + + const result = await (await fetch(topicURL, { + headers: {'Content-Type': 'application/json'} + })).json(); + + this.topic = result; + + const fetchOne = async (url) => { + const result = await fetch(url, { + headers: {'Content-Type': 'application/json'} + }); + if (!result.ok) + throw result; + + const jsondata = await result.json(); + if (jsondata["element"] === undefined) + throw new Error("no element defined in metadata"); + + return jsondata; + }; + + let promises = []; + for (const activity of result.activities) { + const actURL = new URL(activity.path, new URL(topicURL, window.location).href).href; + promises.push([activity.visible === undefined || activity.visible, actURL, fetchOne(actURL)]); + } + + for (const [visible, actURL, p] of promises) { + try { + const activity = await p; + activity.visible = visible; + // Resolve module_src relative to the location of the json file + activity.module_src = new URL(activity.module_src, actURL).href; + metadata[activity.routing_name] = activity; + routes.push(activity.routing_name); + } catch (error) { + console.log(error); + } + } + // this also triggers a rebuilding of the menu + this.metadata = metadata; + this.routes = routes; + + // Switch to the first route if none is selected + if (!this.activeView) + this.switchComponent(routes[0]); + else + this.switchComponent(this.activeView); + + } + + initRouter() { + const routes = [ + { + path: '', + action: (context) => { + return { + lang: this.lang, + component: '', + }; + } + }, + { + path: '/:lang', + children: [ + { + path: '', + action: (context, params) => { + return { + lang: params.lang, + component: '', + }; + } + }, + { + name: 'mainRoute', + path: '/:component', + action: (context, params) => { + // remove the additional parameters added by Keycloak + let componentTag = params.component.toLowerCase().replace(/&.+/,""); + return { + lang: params.lang, + component: componentTag, + }; + }, + }, + ], + }, + ]; + + this.router = new Router(routes, { + routeName: 'mainRoute', + getState: () => { + return { + component: this.activeView, + lang: this.lang, + }; + }, + setState: (state) => { + this.updateLangIfChanged(state.lang); + this.switchComponent(state.component); + } + }, { + baseUrl: new URL(this.basePath, window.location).pathname.replace(/\/$/, ''), + }); + + this.router.setStateFromCurrentLocation(); + } + + static get properties() { + return { + lang: { type: String, reflect: true }, + src: { type: String }, + basePath: { type: String, attribute: 'base-path' }, + activeView: { type: String, attribute: false}, + entryPointUrl: { type: String, attribute: 'entry-point-url' }, + metadata: { type: Object, attribute: false }, + topic: { type: Object, attribute: false }, + subtitle: { type: String, attribute: false }, + description: { type: String, attribute: false }, + _loginStatus: { type: Boolean, attribute: false }, + }; + } + + _updateAuth(login) { + if (login.status != this._loginStatus) { + console.log('Login status: ' + login.status); + } + + this._loginStatus = login.status; + + // Clear the session storage when the user logs out + if (this._loginStatus === 'logging-out') { + sessionStorage.clear(); + } + } + + connectedCallback() { + super.connectedCallback(); + + if (this.src) + this.fetchMetadata(this.src); + this.initRouter(); + + // listen to the vpu-auth-profile event to switch to the person profile + window.addEventListener("vpu-auth-profile", () => { + this.switchComponent('person-profile'); + }); + + this._subscriber.subscribe(this._updateAuth); + } + + disconnectedCallback() { + this._subscriber.unsubscribe(this._updateAuth); + super.disconnectedCallback(); + } + + /** + * Switches language if another language is requested + * + * @param {string} lang + */ + updateLangIfChanged(lang) { + if (this.lang !== lang) { + this.lang = lang; + this.router.update(); + + const event = new CustomEvent("vpu-language-changed", { + bubbles: true, + detail: {'lang': lang} + }); + + this.dispatchEvent(event); + } + } + + update(changedProperties) { + changedProperties.forEach((oldValue, propName) => { + if (propName === "lang") { + // For screen readers + document.documentElement.setAttribute("lang", this.lang); + i18n.changeLanguage(this.lang); + } + }); + + super.update(changedProperties); + } + + onMenuItemClick(e) { + e.preventDefault(); + const link = e.composedPath()[0]; + const location = link.getAttribute('href'); + this.router.updateFromPathname(location); + } + + onLanguageChanged(e) { + const newLang = e.detail.lang; + const changed = (this.lang !== newLang); + this.lang = newLang; + if (changed) { + this.router.update(); + this.subtitle = this.activeMetaDataText("short_name"); + this.description = this.activeMetaDataText("description"); + } + } + + switchComponent(componentTag) { + const changed = (componentTag !== this.activeView); + this.activeView = componentTag; + if (changed) + this.router.update(); + const metadata = this.metadata[componentTag]; + + if (metadata === undefined) { + return; + } + + importNotify(import(metadata.module_src)).then(() => { + this.updatePageTitle(); + this.subtitle = this.activeMetaDataText("short_name"); + this.description = this.activeMetaDataText("description"); + }).catch((e) => { + console.error(`Error loading ${ metadata.element }`); + throw e; + }); + } + + metaDataText(routingName, key) { + const metadata = this.metadata[routingName]; + return metadata !== undefined && metadata[key] !== undefined ? metadata[key][this.lang] : ''; + } + + topicMetaDataText(key) { + return (this.topic[key] !== undefined) ? this.topic[key][this.lang] : ''; + } + + activeMetaDataText(key) { + return this.metaDataText(this.activeView, key); + } + + updatePageTitle() { + document.title = `${this.topicMetaDataText('name')} - ${this.activeMetaDataText("short_name")}`; + } + + toggleMenu() { + const menu = this.shadowRoot.querySelector("ul.menu"); + + if (menu === null) { + return; + } + + menu.classList.toggle('hidden'); + + const chevron = this.shadowRoot.querySelector("#menu-chevron-icon"); + if (chevron !== null) { + chevron.name = menu.classList.contains('hidden') ? 'chevron-down' : 'chevron-up'; + } + } + + static get styles() { + // language=css + return css` + ${commonStyles.getThemeCSS()} + ${commonStyles.getGeneralCSS()} + + .hidden {display: none} + + h1.title { + margin-bottom: 0; + font-weight: 300; + } + + #main { + display: grid; + grid-template-columns: minmax(180px, 17%) minmax(0, auto); + grid-template-rows: min-content min-content 1fr min-content; + grid-template-areas: "header header" "headline headline" "sidebar main" "footer footer"; + max-width: 1400px; + margin: auto; + min-height: 100vh; + } + + #main-logo { + padding: 0 50px 0 0; + } + + header { + grid-area: header; + display: grid; + grid-template-columns: 50% 1px auto; + grid-template-rows: 60px 60px; + grid-template-areas: "hd1-left hd1-middle hd1-right" "hd2-left . hd2-right"; + width: 100%; + max-width: 1060px; + margin: 0 auto; + } + + aside { grid-area: sidebar; margin: 30px 15px; } + #headline { grid-area: headline; margin: 15px; text-align: center; } + main { grid-area: main; margin: 30px 15px; } + footer { grid-area: footer; margin: 30px; text-align: right; } + + header .hd1-left { + display: flex; + flex-direction: column; + justify-content: center; + grid-area: hd1-left; + text-align: right; + padding-right: 20px; + } + + header .hd1-middle { + grid-area: hd1-middle; + background-color: #000; + background: linear-gradient(180deg, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 90%); + } + + header .hd1-right { + grid-area: hd1-right; + display: flex; + flex-direction: column; + justify-content: center; + padding-left: 20px; + } + + header .hd2-left { + grid-area: hd2-left; + display: flex; + flex-direction: column; + white-space: nowrap; + } + + header .hd2-left .header { + margin-left: 50px; + } + + header .hd2-left a:hover { + color: #fff; + background-color: #000; + } + + header .hd2-right { + grid-area: hd2-right; + display: flex; + flex-direction: column; + justify-content: center; + text-align: right; + } + + header a { + color: black; + display: inline; + } + + aside ul.menu, footer ul.menu { + list-style: none; + } + + ul.menu li.close { + display: none; + } + + footer { + display: grid; + grid-gap: 1em; + grid-template-columns: 1fr max-content max-content; + } + + footer a { + border-bottom: 1px solid rgba(0,0,0,0.3); + padding: 0; + } + + footer a:hover { + color: #fff; + background-color: #000; + } + + /* We don't allown inline-svg */ + /* + footer .int-link-external::after { + content: "\\00a0\\00a0\\00a0\\00a0"; + background-image: url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3Ardf%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2F02%2F22-rdf-syntax-ns%23%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20height%3D%225.6842mm%22%20width%3D%225.6873mm%22%20version%3D%221.1%22%20xmlns%3Acc%3D%22http%3A%2F%2Fcreativecommons.org%2Fns%23%22%20xmlns%3Adc%3D%22http%3A%2F%2Fpurl.org%2Fdc%2Felements%2F1.1%2F%22%20viewBox%3D%220%200%2020.151879%2020.141083%22%3E%3Cg%20transform%3D%22translate(-258.5%20-425.15)%22%3E%3Cpath%20style%3D%22stroke-linejoin%3Around%3Bstroke%3A%23000%3Bstroke-linecap%3Around%3Bstroke-width%3A1.2%3Bfill%3Anone%22%20d%3D%22m266.7%20429.59h-7.5029v15.002h15.002v-7.4634%22%2F%3E%3Cpath%20style%3D%22stroke-linejoin%3Around%3Bstroke%3A%23000%3Bstroke-linecap%3Around%3Bstroke-width%3A1.2%3Bfill%3Anone%22%20d%3D%22m262.94%20440.86%2015.002-15.002%22%2F%3E%3Cpath%20style%3D%22stroke-linejoin%3Around%3Bstroke%3A%23000%3Bstroke-linecap%3Around%3Bstroke-width%3A1.2%3Bfill%3Anone%22%20d%3D%22m270.44%20425.86h7.499v7.499%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E'); + background-size:contain; + background-repeat: no-repeat; + background-position:center center; + margin: 0 0.5% 0 1.5%; + font-size:94%; + } + */ + + .menu a { + padding: 0.3em; + font-weight: 300; + color: #000; + display: block; + } + + .menu a:hover { + color: #E4154B; + } + + .menu a.selected { + color: var(--vpu-light); + background-color: var(--vpu-dark); + } + + aside .subtitle { + display: none; + color: #4a4a4a; + font-size: 1.25rem; + font-weight: 300; + line-height: 1.25; + cursor: pointer; + text-align: center; + } + + ul.menu.hidden { + display: block; + } + + a { transition: background-color 0.15s ease 0s, color 0.15s ease 0s; } + + .description { + text-align: left; + margin-bottom: 1rem; + display: none; + } + + @media (max-width: 680px) { + #main { + grid-template-columns: minmax(0, auto); + grid-template-rows: min-content min-content min-content 1fr min-content; + grid-template-areas: "header" "headline" "sidebar" "main" "footer"; + } + + header { + grid-template-rows: 40px; + grid-template-areas: "hd1-left hd1-middle hd1-right"; + } + + header .hd2-left, header .hd2-right { + display: none; + } + + aside { + margin: 0 15px; + } + + aside h2.subtitle { + display: block; + margin-bottom: 0.5em; + } + + aside h2.subtitle:not(:last-child) { + margin-bottom: 0.5em; + } + + aside .menu { + border: black 1px solid; + } + + .menu li { + padding: 7px; + } + + .menu a { + padding: 8px; + } + + ul.menu li.close { + display: block; + padding: 0 15px 15px 15px; + text-align: right; + cursor: pointer; + } + + ul.menu.hidden { + display: none; + } + } + `; + } + + _createActivityElement(activity) { + // We have to create elements dynamically based on a tag name which isn't possible with lit-html. + // This means we pass the finished element to lit-html and have to handle element caching and + // event binding ourselves. + + if (this._lastElm !== undefined) { + if (this._lastElm.tagName.toLowerCase() == activity.element.toLowerCase()) { + return this._lastElm; + } else { + this._attrObserver.disconnect(); + this._lastElm = undefined; + } + } + + const elm = document.createElement(activity.element); + + for(const key of this.topic.attributes) { + let value = sessionStorage.getItem('vpu-attr-' + key); + if (value !== null) { + elm.setAttribute(key, value); + } + } + + this._attrObserver.observe(elm, {attributes: true, attributeFilter: this.topic.attributes}); + + this._lastElm = elm; + return elm; + } + + _renderActivity() { + const act = this.metadata[this.activeView]; + if (act === undefined) + return html``; + + const elm = this._createActivityElement(act); + elm.setAttribute("entry-point-url", this.entryPointUrl); + elm.setAttribute("lang", this.lang); + return elm; + } + + render() { + const silentCheckSsoUri = commonUtils.getAssetURL('silent-check-sso.html'); + + const getSelectClasses = (name => { + return classMap({selected: this.activeView === name}); + }); + + // We hide the app until we are either fully logged in or logged out + // At the same time when we hide the main app we show the main slot (e.g. a loading spinner) + const appHidden = (this._loginStatus == 'unknown' || this._loginStatus == 'logging-in'); + const mainClassMap = classMap({hidden: appHidden}); + const slotClassMap = classMap({hidden: !appHidden}); + + // XXX: Safari doesn't like CSS being applied to slots or via HTML, + // so we have to remove the slow instead of hiding it + if (!appHidden) { + this.updateComplete.then(() => { + const slot = this.shadowRoot.querySelector("slot"); + if (slot) + slot.remove(); + }); + } + + const prodClassMap = classMap({hidden: buildinfo.env === 'production'}); + + this.updatePageTitle(); + + // build the menu + let menuTemplates = []; + for (let routingName of this.routes) { + const data = this.metadata[routingName]; + + if (data['visible']) { + menuTemplates.push(html`<li><a @click="${(e) => this.onMenuItemClick(e)}" href="${this.router.getPathname({component: routingName})}" data-nav class="${getSelectClasses(routingName)}" title="${this.metaDataText(routingName, "description")}">${this.metaDataText(routingName, "short_name")}</a></li>`); + } + } + + return html` + <slot class="${slotClassMap}"></slot> + <div class="${mainClassMap}"> + <div id="main"> + <vpu-notification lang="${this.lang}"></vpu-notification> + <header> + <div class="hd1-left"> + <vpu-language-select @vpu-language-changed=${this.onLanguageChanged.bind(this)}></vpu-language-select> + </div> + <div class="hd1-middle"> + </div> + <div class="hd1-right"> + <vpu-auth lang="${this.lang}" show-profile keycloak-config='{"clientId": "${commonUtils.setting('keyCloakClientId')}", "silentCheckSsoRedirectUri": "${silentCheckSsoUri}"}' load-person try-login></vpu-auth> + </div> + <div class="hd2-left"> + <div class="header"> + <a href="https://www.tugraz.at/" title="TU Graz Home" target="_blank" rel="noopener">TU Graz<br>Graz University of Technology</a> + </div> + </div> + <div class="hd2-right"> + <vpu-tugraz-logo id="main-logo" lang="${this.lang}"></vpu-tugraz-logo> + </div> + </header> + + <div id="headline"> + <h1 class="title">${this.topicMetaDataText('name')}</h1> + </div> + + <aside> + <h2 class="subtitle" @click="${this.toggleMenu}"> + ${this.subtitle} + <vpu-icon name="chevron-down" style="color: red" id="menu-chevron-icon"></vpu-icon> + </h2> + <ul class="menu hidden"> + ${menuTemplates} + <li class="close" @click="${this.toggleMenu}"><vpu-icon name="close" style="color: red"></vpu-icon></li> + </ul> + </aside> + + <main> + <p class="description">${this.description}</p> + ${ this._renderActivity() } + </main> + + <footer> + <div></div> + <a target="_blank" rel="noopener" class="int-link-external" href="https://datenschutz.tugraz.at/erklaerung/">${i18n.t('privacy-policy')}</a> + <vpu-build-info class="${prodClassMap}"></vpu-build-info> + </footer> + </div> + </div> + `; + } +} + +commonUtils.defineCustomElement('vpu-app', VPUApp); diff --git a/packages/app-shell/src/router.js b/packages/app-shell/src/router.js new file mode 100644 index 0000000000000000000000000000000000000000..2ee466094ed5d6e7531e11ecd9ea7b9c354feb26 --- /dev/null +++ b/packages/app-shell/src/router.js @@ -0,0 +1,117 @@ +import UniversalRouter from 'universal-router'; +import generateUrls from 'universal-router/generateUrls'; + +/** + * A wrapper around UniversalRouter which adds history integration + */ +export class Router { + + /** + * @param {Array} routes The routes passed to UniversalRouter + * @param {object} options Options + * @param {string} options.routeName The main route name + * @param {Function} options.getState Function which should return the current state + * @param {Function} options.setState Function which gets passed the new state based on the route + * @param {object} unioptions options passed to UniversalRouter + */ + constructor(routes, options, unioptions) { + this.getState = options.getState; + this.setState = options.setState; + // XXX: We only have one route atm + // If we need more we need to pass the route name to each function + this.routeName = options.routeName; + + console.assert(this.getState); + console.assert(this.setState); + console.assert(this.routeName); + + // https://github.com/kriasoft/universal-router + this.router = new UniversalRouter(routes, unioptions); + + window.addEventListener('popstate', (event) => { + this.setStateFromCurrentLocation(); + this.dispatchLocationChanged(); + }); + } + + /** + * In case something else has changed the location, update the app state accordingly. + */ + setStateFromCurrentLocation() { + const oldPathName = location.pathname; + this.router.resolve({pathname: oldPathName}).then(page => { + const newPathname = this.getPathname(page); + // In case of a router redirect, set the new location + if (newPathname !== oldPathName) { + const referrerUrl = location.href; + window.history.replaceState({}, '', newPathname); + this.dispatchLocationChanged(referrerUrl); + } + this.setState(page); + }).catch((e) => { + // In case we can't resolve the location, just leave things as is. + // This happens when a user enters a wrong URL or when testing with karma. + }); + } + + /** + * Update the router after some internal state change. + */ + update() { + // Queue updates so we can call this multiple times when changing state + // without it resulting in multiple location changes + setTimeout(() => { + const newPathname = this.getPathname(); + const oldPathname = location.pathname; + if (newPathname === oldPathname) + return; + const referrerUrl = location.href; + window.history.pushState({}, '', newPathname); + this.dispatchLocationChanged(referrerUrl); + }); + } + + /** + * Given a new routing path set the location and the app state. + * + * @param {string} pathname + */ + updateFromPathname(pathname) { + this.router.resolve({pathname: pathname}).then(page => { + if (location.pathname === pathname) + return; + const referrerUrl = location.href; + window.history.pushState({}, '', pathname); + this.setState(page); + this.dispatchLocationChanged(referrerUrl); + }).catch((err) => { + throw new Error(`Route not found: ${pathname}: ${err}`); + }); + } + + /** + * Pass some new router state to get a new router path that can + * be passed to updateFromPathname() later on. If nothing is + * passed the current state is used. + * + * @param {object} [partialState] The optional partial new state + * @returns {string} The new path + */ + getPathname(partialState) { + const currentState = this.getState(); + if (partialState === undefined) + partialState = {}; + let combined = {...currentState, ...partialState}; + return generateUrls(this.router)(this.routeName, combined); + } + + dispatchLocationChanged(referrerUrl = "") { + // fire a locationchanged event + window.dispatchEvent(new CustomEvent('locationchanged', { + detail: { + referrerUrl: referrerUrl, + }, + bubbles: true + })); + } +} diff --git a/packages/app-shell/src/tugraz-logo.js b/packages/app-shell/src/tugraz-logo.js new file mode 100644 index 0000000000000000000000000000000000000000..51b654cddca0d3670824dca5c6dbbc37cca2c35f --- /dev/null +++ b/packages/app-shell/src/tugraz-logo.js @@ -0,0 +1,82 @@ +import {html, LitElement, css} from 'lit-element'; +import * as commonUtils from 'vpu-common/utils'; +import * as commonStyles from 'vpu-common/styles'; +import {createI18nInstance} from './i18n.js'; + +const i18n = createI18nInstance(); + +class VPUTUGrazLogo extends LitElement { + + constructor() { + super(); + + this.lang = i18n.language; + } + + static get properties() { + return { + lang: { type: String } + }; + } + + update(changedProperties) { + changedProperties.forEach((oldValue, propName) => { + if (propName === "lang") { + i18n.changeLanguage(this.lang); + } + }); + super.update(changedProperties); + } + + static get styles() { + return css` + ${commonStyles.getThemeCSS()} + ${commonStyles.getGeneralCSS()} + + :host { + display: inline-block; + } + + #claim + { + font-size: 12px; + text-align: right; + padding: 0 17px 0 0; + line-height: 17px; + letter-spacing: 2px; + vertical-align: top; + text-transform: uppercase; + display: inline-block; + white-space: nowrap; + } + + #img { + overflow: visible; + } + + a:hover path, a:focus path { + fill:#000 !important; + transition:none; + } + + * { + transition:fill 0.15s, stroke 0.15s; + } + `; + } + + render() { + return html` + <a href="https://www.tugraz.at" title="TU Graz Home" target="_blank" rel="noopener"> + <div id="claim"> + <div class="int-header-logo-claim-single">${i18n.t('logo.word1')}</div> + <div class="int-header-logo-claim-single">${i18n.t('logo.word2')}</div> + <div class="int-header-logo-claim-single">${i18n.t('logo.word3')}</div> + </div> + <svg id="img" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" height="51.862" width="141.1" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 141.10001 51.862499"><g transform="matrix(1.25 0 0 -1.25 0 51.862)"><g transform="scale(.1)"><path style="fill:#e4154b" d="m0 103.73h207.45v207.46l-207.45 0.01v-207.47z"></path><path style="fill:#e4154b" d="m228.19 103.73h207.46v207.46h-207.46v-207.46z"></path><path style="fill:#e4154b" d="m456.41 103.73h207.44v207.46h-207.44v-207.46z"></path><path style="fill:#e4154b" d="m103.72 0h207.47v207.46h-207.47v-207.46z"></path><path style="fill:#e4154b" d="m352.68 207.46h207.44v207.46h-207.44v-207.46z"></path><path style="fill:#231f20" d="m751.04 277.91h-66.426v33.195h171.19v-33.195h-66.407v-173.73h-38.359v173.73"></path><path style="fill:#231f20" d="m1048.3 180.22c0-12.461-2.25-23.711-6.72-33.75-4.5-10.039-10.61-18.555-18.36-25.567-7.76-7.031-16.9-12.421-27.503-16.21-10.605-3.809-22.109-5.7036-34.551-5.7036-12.422 0-23.945 1.8946-34.551 5.7036-10.605 3.789-19.824 9.179-27.656 16.21-7.851 7.012-13.984 15.528-18.34 25.567-4.394 10.039-6.582 21.289-6.582 33.75v130.89h38.379v-129.59c0-5.039 0.801-10.351 2.442-15.898 1.64-5.547 4.336-10.664 8.125-15.332s8.789-8.516 15.039-11.523c6.211-3.008 13.926-4.512 23.144-4.512 9.199 0 16.914 1.504 23.145 4.512 6.23 3.007 11.25 6.855 15.039 11.523 3.77 4.668 6.48 9.785 8.12 15.332 1.63 5.547 2.45 10.859 2.45 15.898v129.59h38.38v-130.89"></path><path style="fill:#231f20" d="m832.56 75.664c-7.597 3.2812-17.46 4.8632-25.332 4.8632-22.929 0-35.605-14.434-35.605-33.184 0-18.613 12.383-32.637 33.34-32.637 5.351 0 9.59 0.5274 12.969 1.3086v23.867h-20.84v14.414h39.687v-49.297c-10.41-2.6172-21.25-4.707-31.816-4.707-31.797 0-53.906 14.805-53.906 45.742 0 31.348 20.566 48.906 53.906 48.906 11.406 0 20.41-1.4453 28.867-3.8086l-1.27-15.469"></path><path style="fill:#231f20" d="m856.2 69.375h16.758v-15.332h0.293c0.84 6.289 8.574 16.914 19.824 16.914 1.836 0 3.828 0 5.782-0.5273v-17.715c-1.68 0.918-5.059 1.4454-8.457 1.4454-15.333 0-15.333-17.832-15.333-27.52v-24.785h-18.867v67.52"></path><path style="fill:#231f20" d="m913.75 65.84c7.324 3.1446 17.187 5.1172 25.215 5.1172 22.09 0 31.23-8.5351 31.23-28.457v-8.6523c0-6.8165 0.156-11.934 0.293-16.914 0.137-5.1172 0.41-9.8242 0.84-15.078h-16.602c-0.703 3.5352-0.703 8.0078-0.839 10.098h-0.293c-4.36-7.4618-13.81-11.661-22.38-11.661-12.793 0-25.332 7.207-25.332 20.059 0 10.078 5.195 15.976 12.383 19.258 7.187 3.2812 16.464 3.9453 24.355 3.9453h10.41c0 10.879-5.195 14.551-16.328 14.551-8.008 0-16.035-2.8907-22.363-7.3438l-0.586 15.078zm22.11-52.715c5.782 0 10.274 2.3633 13.223 6.0352 3.105 3.8086 3.945 8.6523 3.945 13.906h-8.164c-8.437 0-20.957-1.3086-20.957-11.68 0-5.7617 5.195-8.2617 11.953-8.2617"></path><path style="fill:#231f20" d="m985.69 69.375h57.422v-14.414l-36.04-39.473h37.31v-13.633h-60.235v14.297l36.715 39.59h-35.172v13.633"></path><path style="fill:#e4154b" d="m1059.6 0h69.102v69.121h-69.102v-69.121z"></path></g></g></svg> + </a> + `; + } +} + +commonUtils.defineCustomElement('vpu-tugraz-logo', VPUTUGrazLogo); diff --git a/packages/app-shell/src/vpu-activity-example.js b/packages/app-shell/src/vpu-activity-example.js new file mode 100644 index 0000000000000000000000000000000000000000..f4be9f9fe2b3091e3762aa7a24413c9141ca24c3 --- /dev/null +++ b/packages/app-shell/src/vpu-activity-example.js @@ -0,0 +1,39 @@ +import {html , LitElement} from 'lit-element'; +import {createI18nInstance} from './i18n.js'; +import * as commonUtils from 'vpu-common/utils'; + +const i18n = createI18nInstance(); + +class ActivityExample extends LitElement { + + constructor() { + super(); + this.lang = i18n.language; + } + + static get properties() { + return { + lang: { type: String }, + }; + } + + update(changedProperties) { + changedProperties.forEach((oldValue, propName) => { + switch (propName) { + case "lang": + i18n.changeLanguage(this.lang); + break; + } + }); + + super.update(changedProperties); + } + + render() { + return html` + <p>${ i18n.t('activity-example.hello-world') }</p> + `; + } +} + +commonUtils.defineCustomElement('vpu-activity-example', ActivityExample); diff --git a/packages/app-shell/test/unit.js b/packages/app-shell/test/unit.js new file mode 100644 index 0000000000000000000000000000000000000000..1ae456f0da3ee6120af8535c28df58bafac02b22 --- /dev/null +++ b/packages/app-shell/test/unit.js @@ -0,0 +1,29 @@ +import {assert} from 'chai'; + +import {Router} from '../src/router.js'; + +suite('router', () => { + + test('basics', () => { + const routes = [ + { + name: 'foo', + path: '', + action: (context) => { + return {}; + } + }, + ]; + + const router = new Router(routes, { + routeName: 'foo', + getState: () => { return {}; }, + setState: (state) => { }, + }); + + router.setStateFromCurrentLocation(); + router.update(); + router.updateFromPathname("/"); + assert.equal(router.getPathname(), '/'); + }); +}); \ No newline at end of file diff --git a/packages/app-shell/vendor/auth b/packages/app-shell/vendor/auth new file mode 160000 index 0000000000000000000000000000000000000000..0ec14423f6d7472608b0ee1bdaded8a0e89dc331 --- /dev/null +++ b/packages/app-shell/vendor/auth @@ -0,0 +1 @@ +Subproject commit 0ec14423f6d7472608b0ee1bdaded8a0e89dc331 diff --git a/packages/app-shell/vendor/common b/packages/app-shell/vendor/common new file mode 160000 index 0000000000000000000000000000000000000000..2e8b0cea73254a82df4dfb5d5dcb2439d057e6a8 --- /dev/null +++ b/packages/app-shell/vendor/common @@ -0,0 +1 @@ +Subproject commit 2e8b0cea73254a82df4dfb5d5dcb2439d057e6a8 diff --git a/packages/app-shell/vendor/language-select b/packages/app-shell/vendor/language-select new file mode 160000 index 0000000000000000000000000000000000000000..9a3c9052ed1809b302d2738e6aa3da7e4d29b819 --- /dev/null +++ b/packages/app-shell/vendor/language-select @@ -0,0 +1 @@ +Subproject commit 9a3c9052ed1809b302d2738e6aa3da7e4d29b819 diff --git a/packages/app-shell/vendor/notification b/packages/app-shell/vendor/notification new file mode 160000 index 0000000000000000000000000000000000000000..b4e2cbb05eb4c4b57f48d96da1caf9b0e1a1d2a1 --- /dev/null +++ b/packages/app-shell/vendor/notification @@ -0,0 +1 @@ +Subproject commit b4e2cbb05eb4c4b57f48d96da1caf9b0e1a1d2a1 diff --git a/packages/app-shell/vendor/person-profile b/packages/app-shell/vendor/person-profile new file mode 160000 index 0000000000000000000000000000000000000000..f0c0e9437906d06618036df0af53c9ba47f08b1f --- /dev/null +++ b/packages/app-shell/vendor/person-profile @@ -0,0 +1 @@ +Subproject commit f0c0e9437906d06618036df0af53c9ba47f08b1f