diff --git a/packages/app-shell/src/app-shell.js b/packages/app-shell/src/app-shell.js new file mode 100644 index 0000000000000000000000000000000000000000..34ecb520b9fda7e72a40f30939371eab53bfefe7 --- /dev/null +++ b/packages/app-shell/src/app-shell.js @@ -0,0 +1,730 @@ +import {createI18nInstance} from './i18n.js'; +import {html, css, LitElement} from 'lit-element'; +import {ScopedElementsMixin} from '@open-wc/scoped-elements'; +import {LanguageSelect} from '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 {BuildInfo} from './build-info.js'; +import {TUGrazLogo} from './tugraz-logo.js'; +import {send as notify} from 'vpu-notification'; +import {userProfileMeta} from './vpu-app-shell-user-profile.js'; +import {appWelcomeMeta} from './vpu-app-shell-welcome.js'; + + +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; + } +}; + +export class AppShell extends ScopedElementsMixin(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); + } + + static get scopedElements() { + return { + 'vpu-language-select': LanguageSelect, + 'vpu-tugraz-logo': TUGrazLogo, + 'vpu-build-info': BuildInfo, + // https://github.com/open-wc/open-wc/issues/1541 + 'vpu-auth': customElements.get('vpu-auth'), + 'vpu-notification': customElements.get('vpu-notification'), + 'vpu-icon': customElements.get('vpu-icon'), + }; + } + + 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) { + let metadata = {}; + let 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); + } + } + + // Inject the user profile and welcome activity + routes.push("user-profile"); + routes.unshift("welcome"); + metadata = Object.assign(metadata, { + "user-profile": userProfileMeta, + "welcome": appWelcomeMeta, + }); + customElements.get("vpu-app-shell-welcome").app = this; + + // 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('user-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; + } + + let updateFunc = () => { + this.updatePageTitle(); + this.subtitle = this.activeMetaDataText("short_name"); + this.description = this.activeMetaDataText("description"); + }; + + // If it is empty assume the element is already registered through other means + if (!metadata.module_src) { + updateFunc(); + return; + } + + importNotify(import(metadata.module_src)).then(() => { + updateFunc(); + }).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> + `; + } +} \ No newline at end of file diff --git a/packages/app-shell/src/index.js b/packages/app-shell/src/index.js index 7fda786e371d714207ac36b98aae5115452b72f8..e7ac96a3ddeb18382bbb3895a676dde23fbd1f17 100644 --- a/packages/app-shell/src/index.js +++ b/packages/app-shell/src/index.js @@ -1,732 +1,3 @@ -import {createI18nInstance} from './i18n.js'; -import {html, css, LitElement} from 'lit-element'; -import {ScopedElementsMixin} from '@open-wc/scoped-elements'; -import {LanguageSelect} from '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 {BuildInfo} from './build-info.js'; -import {TUGrazLogo} from './tugraz-logo.js'; -import {send as notify} from 'vpu-notification'; -import {userProfileMeta} from './vpu-app-shell-user-profile.js'; -import {appWelcomeMeta} from './vpu-app-shell-welcome.js'; +import {AppShell} from './app-shell.js'; - -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 ScopedElementsMixin(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); - } - - static get scopedElements() { - return { - 'vpu-language-select': LanguageSelect, - 'vpu-tugraz-logo': TUGrazLogo, - 'vpu-build-info': BuildInfo, - // https://github.com/open-wc/open-wc/issues/1541 - 'vpu-auth': customElements.get('vpu-auth'), - 'vpu-notification': customElements.get('vpu-notification'), - 'vpu-icon': customElements.get('vpu-icon'), - }; - } - - 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) { - let metadata = {}; - let 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); - } - } - - // Inject the user profile and welcome activity - routes.push("user-profile"); - routes.unshift("welcome"); - metadata = Object.assign(metadata, { - "user-profile": userProfileMeta, - "welcome": appWelcomeMeta, - }); - customElements.get("vpu-app-shell-welcome").app = this; - - // 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('user-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; - } - - let updateFunc = () => { - this.updatePageTitle(); - this.subtitle = this.activeMetaDataText("short_name"); - this.description = this.activeMetaDataText("description"); - }; - - // If it is empty assume the element is already registered through other means - if (!metadata.module_src) { - updateFunc(); - return; - } - - importNotify(import(metadata.module_src)).then(() => { - updateFunc(); - }).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-shell', VPUApp); +export {AppShell}; \ No newline at end of file