@digital-blueprint/lunchlottery-app
Version:
[GitHub Repository](https://github.com/digital-blueprint/lunchlottery-app) | [npmjs package](https://www.npmjs.com/package/@digital-blueprint/lunchlottery-app) | [Unpkg CDN](https://unpkg.com/browse/@digital-blueprint/lunchlottery-app/)
1,386 lines (1,198 loc) • 105 kB
JavaScript
import {createInstance} from './i18n.js';
import {html, css} from 'lit';
import {ScopedElementsMixin, LangMixin, sendNotification} from '@dbp-toolkit/common';
import {LanguageSelect} from '@dbp-toolkit/language-select';
import {Icon} from '@dbp-toolkit/common';
import {AuthKeycloak} from '@dbp-toolkit/auth';
import {AuthMenuButton} from './auth-menu-button.js';
import {Notification} from '@dbp-toolkit/notification';
import {ThemeSwitcher} from '@dbp-toolkit/theme-switcher';
import {Themed} from '@dbp-toolkit/theme-switcher';
import * as commonStyles from '@dbp-toolkit/common/styles';
import {classMap} from 'lit/directives/class-map.js';
import {Router} from './router.js';
import {BuildInfo} from './build-info.js';
import {appWelcomeMeta} from './dbp-app-shell-welcome.js';
import {MatomoElement} from '@dbp-toolkit/matomo/src/matomo';
import DBPLitElement from '@dbp-toolkit/common/dbp-lit-element';
import {FeatureFlagDropdown} from './feature-flag-dropdown.js';
/**
* In case the application gets updated future dynamic imports might fail.
* This sends a notification suggesting the user to reload the page.
*
* usage: importNotify(import('<path>'));
*
* @param i18n
* @param {Promise} promise
*/
const importNotify = async (i18n, promise) => {
try {
return await promise;
} catch (error) {
console.log(error);
sendNotification({
body: i18n.t('page-updated-needs-reload'),
type: 'info',
icon: 'warning',
});
throw error;
}
};
export class AppShell extends LangMixin(ScopedElementsMixin(DBPLitElement), createInstance) {
constructor() {
super();
this.activeView = '';
this.entryPointUrl = '';
this.subtitle = '';
this.description = '';
this.routes = [];
this.visibleRoutes = [];
this.metadata = {};
this.topic = {};
this.basePath = '/';
this.keycloakConfig = null;
this.noWelcomePage = false;
this.menuHeight = -1;
this.gitInfo = '';
this.env = '';
this.buildUrl = '';
this.buildTime = '';
this._loginStatus = 'unknown';
this._roles = [];
this._extra = undefined;
this.matomoUrl = '';
this.matomoSiteId = -1;
this._attrObserver = new MutationObserver(this.onAttributeObserved);
this._onShowActivityEvent = this._onShowActivityEvent.bind(this);
this.boundCloseMenuHandler = this.hideMenu.bind(this);
this.initateOpenMenu = false;
this.auth = null;
this.langDir = '';
this.routingBaseUrl = null;
this.isScrollTopButtonVisible = false;
this.isScrollBottomButtonVisible = true;
}
static get scopedElements() {
return {
'dbp-language-select': LanguageSelect,
'dbp-build-info': BuildInfo,
'dbp-feature-flag-dropdown': FeatureFlagDropdown,
'dbp-auth-keycloak': AuthKeycloak,
'dbp-auth-menu-button': AuthMenuButton,
'dbp-theme-switcher': ThemeSwitcher,
'dbp-themed': Themed,
'dbp-notification': Notification,
'dbp-icon': Icon,
'dbp-matomo': MatomoElement,
};
}
onAttributeObserved(mutationsList, observer) {
for (let mutation of mutationsList) {
if (mutation.type === 'attributes') {
const key = mutation.attributeName;
const value = mutation.target.getAttribute(key);
sessionStorage.setItem('dbp-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)
.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;
activity.required_roles = activity.required_roles || [];
metadata[activity.routing_name] = activity;
routes.push(activity.routing_name);
} catch (error) {
console.log(error);
}
}
if (!this.noWelcomePage) {
// Inject the welcome activity
routes.unshift('welcome');
metadata = Object.assign(metadata, {
welcome: appWelcomeMeta,
});
customElements.get('dbp-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);
}
firstUpdated() {
super.firstUpdated();
if (!this.isMenuFloating()) {
this.toggleMenu();
}
// Wait for all updates to complete before initializing scroll buttons
this.updateComplete.then(() => {
this.initializeScrollToTopButton();
});
}
initRouter() {
const routes = [
{
path: '',
action: (context) => {
return {
lang: this.lang,
component: '',
extra: undefined,
};
},
},
{
path: '/:lang',
children: [
{
path: '',
action: (context, params) => {
return {
lang: params.lang,
component: '',
extra: undefined,
};
},
},
{
name: 'mainRoute',
path: ['/:component{/*extra}'],
action: (context, params) => {
let componentTag = params.component.toLowerCase();
let extra = params.extra ?? undefined;
return {
lang: params.lang,
component: componentTag,
extra: extra,
};
},
},
],
},
];
this.router = new Router(
routes,
{
routeName: 'mainRoute',
getState: () => {
let state = {
component: this.activeView,
lang: this.lang,
extra: this._extra,
};
return state;
},
setState: (state) => {
this.updateLangIfChanged(state.lang);
this.switchComponent(state.component);
this._extra = state.extra;
this.sendRoutingUrl();
},
getDefaultState: () => {
return {
lang: 'de',
component: this.routes[0],
extra: undefined,
};
},
},
{
baseUrl: new URL(this.basePath, window.location.href).pathname.replace(/\/$/, ''),
},
);
this.router.setStateFromCurrentLocation();
}
sendRoutingUrl() {
const routingBaseUrl =
new URL(this.basePath, window.location.href) +
encodeURIComponent(this.lang) +
'/' +
encodeURIComponent(this.activeView);
this.sendSetPropertyEvent('routing-base-url', routingBaseUrl, true);
let path = (this._extra ?? []).join('/');
// Lit element does not seem to react on empty strings when pressing the back button
if (path === '') {
path = '/';
}
let routingUrl = path + window.location.search + window.location.hash;
console.log('sendRoutingUrl routingUrl', routingUrl);
this.sendSetPropertyEvent('routing-url', routingUrl, true);
}
static get properties() {
return {
...super.properties,
src: {type: String},
basePath: {type: String, attribute: 'base-path'},
activeView: {type: String, attribute: false},
entryPointUrl: {type: String, attribute: 'entry-point-url'},
keycloakConfig: {type: Object, attribute: 'keycloak-config'},
metadata: {type: Object, attribute: false},
visibleRoutes: {type: Array, attribute: false},
topic: {type: Object, attribute: false},
subtitle: {type: String, attribute: false},
description: {type: String, attribute: false},
_loginStatus: {type: Boolean, attribute: false},
_roles: {type: Array, attribute: false},
matomoUrl: {type: String, attribute: 'matomo-url'},
matomoSiteId: {type: Number, attribute: 'matomo-site-id'},
noWelcomePage: {type: Boolean, attribute: 'no-welcome-page'},
gitInfo: {type: String, attribute: 'git-info'},
buildUrl: {type: String, attribute: 'build-url'},
buildTime: {type: String, attribute: 'build-time'},
env: {type: String},
auth: {type: Object},
langDir: {type: String, attribute: 'lang-dir'},
routingUrl: {type: String, attribute: 'routing-url'},
routingBaseUrl: {type: String, attribute: 'routing-base-url'},
isScrollTopButtonVisible: {type: Boolean, attribute: false},
isScrollBottomButtonVisible: {type: Boolean, attribute: false},
};
}
connectedCallback() {
super.connectedCallback();
this.initRouter();
if (this.src) {
this.fetchMetadata(this.src);
}
this._boundResizeHandler = () => this.updateMenuIcon();
window.addEventListener('resize', this._boundResizeHandler);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._boundResizeHandler) {
window.removeEventListener('resize', this._boundResizeHandler);
}
}
/**
* Switches language if another language is requested
*
* @param {string} lang
*/
updateLangIfChanged(lang) {
// in case the language is unknown, fall back to the default
if (!this._i18n.languages.includes(lang)) {
lang = this.lang;
}
if (this.lang !== lang) {
this.lang = lang;
this.router.update();
// tell a dbp-provider to update the "lang" property
this.sendSetPropertyEvent('lang', lang, true);
}
}
update(changedProperties) {
changedProperties.forEach((oldValue, propName) => {
switch (propName) {
case 'lang':
// For screen readers
document.documentElement.setAttribute('lang', this.lang);
this.router.update();
this.subtitle = this.activeMetaDataText('short_name');
this.description = this.activeMetaDataText('description');
// send a dbp-lang event with the language
this.dispatchEvent(
new CustomEvent('dbp-lang', {
bubbles: true,
composed: true,
detail: this.lang,
}),
);
break;
case 'metadata':
{
this._updateVisibleRoutes();
}
break;
case 'auth':
{
// kill event if auth gets default
if (this.auth === null) {
break;
}
this._roles = this.auth._roles;
this._updateVisibleRoutes();
const loginStatus = this.auth['login-status'];
if (loginStatus !== this._loginStatus) {
console.log('Login status: ' + loginStatus);
}
if (loginStatus !== undefined) {
this._loginStatus = loginStatus;
}
// Clear the session storage when the user logs out
if (this._loginStatus === 'logging-out') {
sessionStorage.clear();
}
}
break;
case 'routingUrl':
this.handleRoutingUrlChange();
break;
}
});
super.update(changedProperties);
}
handleRoutingUrlChange() {
if (!this.activeView || !this.lang || this.lang === '' || this.routingUrl === undefined) {
return;
}
console.log('handleRoutingUrlChange this.routingUrl', this.routingUrl);
let routingUrl = this.routingUrl.startsWith('/') ? this.routingUrl : `/${this.routingUrl}`;
// Generate a full routing URL from the routingUrl
const fullUrl = this.basePath + this.lang + '/' + this.activeView + routingUrl;
console.log('handleRoutingUrlChange fullUrl', fullUrl);
this.router.updateFromUrl(fullUrl);
}
isMenuFloating() {
const menu = this.shadowRoot.querySelector('ul.menu');
if (!menu) return false;
const computedStyle = window.getComputedStyle(menu);
return computedStyle.position === 'fixed';
}
updateMenuIcon() {
const menu = this.shadowRoot.querySelector('ul.menu');
const burger = this.shadowRoot.querySelector('#menu-burger-icon');
if (!menu || !burger) return;
const isOpen = menu.classList.contains('is-open');
if (isOpen) {
burger.name = this.isMenuFloating() ? 'chevron-up' : 'chevron-left';
} else {
burger.name = 'menu';
}
}
onMenuItemClick(e) {
e.preventDefault();
// if not the current page was clicked we need to check if the page can be left
if (!e.currentTarget.className.includes('selected')) {
// simulate a "beforeunload" event
const event = new CustomEvent('beforeunload', {
bubbles: true,
cancelable: true,
});
const eventResult = window.dispatchEvent(event);
// if someone canceled the "beforeunload" event we don't want to leave the page
if (!eventResult) {
return;
}
}
const link = e.composedPath()[0];
const location = link.getAttribute('href');
this.router.updateFromUrl(location);
this.hideMenu();
}
/**
* Scroll the page to the top of the active view. Used when switching views.
*/
_scrollToTop() {
let offset = window.pageYOffset;
if (offset > 0) {
const header = this.shadowRoot.querySelector('header');
const title = this.shadowRoot.querySelector('#headline');
if (header === null || title === null) {
return;
}
let style = getComputedStyle(title);
let marginTop = isNaN(parseInt(style.marginTop, 10))
? 0
: parseInt(style.marginTop, 10);
let marginBottom = isNaN(parseInt(style.marginBottom, 10))
? 0
: parseInt(style.marginBottom, 10);
let topValue =
header.getBoundingClientRect().height +
title.getBoundingClientRect().height +
marginTop +
marginBottom;
if (offset < topValue) {
window.scrollTo(0, offset);
} else {
window.scrollTo(0, topValue);
}
}
}
switchComponent(componentTag) {
const changed = componentTag !== this.activeView;
this.activeView = componentTag;
if (changed) this.router.update();
const metadata = this.metadata[componentTag];
if (metadata === undefined) {
return;
}
if (changed) {
this._scrollToTop();
}
this.updatePageTitle();
this.updatePageMetaDescription();
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) {
return;
}
importNotify(this._i18n, import(metadata.module_src)).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() {
let title;
if (this.activeView === 'welcome') {
title = `${this.topicMetaDataText('short_name')}`;
} else {
title = `${this.activeMetaDataText('short_name')} | ${this.topicMetaDataText('short_name')}`;
}
document.title = title;
}
updatePageMetaDescription() {
let metaDesc;
if (this.activeView === 'welcome') {
metaDesc = `${this.topicMetaDataText('description')}`;
} else {
metaDesc = `${this.activeMetaDataText('description')}`;
}
const metaDescElement = document.querySelector('meta[name="description"]');
if (metaDescElement) {
metaDescElement.setAttribute('content', metaDesc);
}
}
toggleMenu() {
const menu = this.shadowRoot.querySelector('ul.menu');
const menuButton = this.shadowRoot.querySelector('.hd1-left-menu');
const burger = this.shadowRoot.querySelector('#menu-burger-icon');
const mainGrid = this.shadowRoot.querySelector('#main');
if (!menu) return;
const isOpening = !menu.classList.contains('is-open');
menu.classList.toggle('is-open', isOpening);
mainGrid?.classList.toggle('menu-open', isOpening);
this.updateMenuIcon();
menuButton?.setAttribute('aria-expanded', String(isOpening));
// Outside click + initial click guard
if (this._boundCloseMenuHandler) {
document.removeEventListener('click', this._boundCloseMenuHandler);
this._boundCloseMenuHandler = null;
}
if (this._boundEscHandler) {
document.removeEventListener('keydown', this._boundEscHandler);
this._boundEscHandler = null;
}
if (isOpening) {
this._boundCloseMenuHandler = (evt) => {
const path = evt.composedPath?.() || [];
const clickedInside =
path.includes(menu) || path.includes(menuButton) || path.includes(burger);
if (!clickedInside) this.hideMenu();
};
setTimeout(() => {
document.addEventListener('click', this._boundCloseMenuHandler);
}, 0);
this._boundEscHandler = (e) => {
if (e.key === 'Escape') this.hideMenu();
};
document.addEventListener('keydown', this._boundEscHandler);
}
}
hideMenu() {
if (!this.isMenuFloating()) {
return;
}
const menu = this.shadowRoot.querySelector('ul.menu');
if (!menu?.classList.contains('is-open')) return;
// Close without re-toggling
menu.classList.remove('is-open');
this.shadowRoot.querySelector('#main')?.classList.remove('menu-open');
this.shadowRoot.querySelector('#menu-burger-icon')?.setAttribute('name', 'menu');
this.shadowRoot.querySelector('h2.subtitle')?.setAttribute('aria-expanded', 'false');
if (this._boundCloseMenuHandler) {
document.removeEventListener('click', this._boundCloseMenuHandler);
this._boundCloseMenuHandler = null;
}
if (this._boundEscHandler) {
document.removeEventListener('keydown', this._boundEscHandler);
this._boundEscHandler = null;
}
}
initializeScrollToTopButton() {
const buttonDisplayOffset = 600;
const scrollTopBtn = this.shadowRoot.getElementById('scroll-top');
const scrollBottomBtn = this.shadowRoot.getElementById('scroll-bottom');
if (!scrollTopBtn || !scrollBottomBtn) return;
const toggleScrollButton = () => {
const pageHeight = document.documentElement.scrollHeight;
let distanceFromTop = window.scrollY;
let distanceFromBottom = pageHeight - (window.scrollY + window.innerHeight);
// Only show the scroll top button if we scroll down buttonDisplayOffset px
if (distanceFromTop < buttonDisplayOffset) {
this.isScrollTopButtonVisible = false;
} else {
this.isScrollTopButtonVisible = true;
}
// Only show the scroll bottom button if we start scrolling down or
// we are at least buttonDisplayOffset px away from the bottom
if (window.scrollY < 100 || distanceFromBottom < buttonDisplayOffset) {
this.isScrollBottomButtonVisible = false;
} else {
this.isScrollBottomButtonVisible = true;
}
};
// Debounce scroll event for better performance
let scrollTimeout;
const debouncedToggleScrollButton = () => {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(toggleScrollButton, 100);
};
window.addEventListener('scroll', debouncedToggleScrollButton, {passive: true});
scrollTopBtn.addEventListener('click', (event) => {
event.preventDefault();
window.scrollTo({
top: 0,
behavior: 'smooth',
});
});
scrollBottomBtn.addEventListener('click', (event) => {
event.preventDefault();
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: 'smooth',
});
});
debouncedToggleScrollButton();
}
static get styles() {
// language=css
return css`
${commonStyles.getThemeCSS()}
${commonStyles.getGeneralCSS()}
${commonStyles.getLinkCss()}
h1.title {
margin-bottom: 0;
font-weight: bold;
}
.title {
color: var(--dbp-content);
font-size: 1em;
line-height: 1.125;
}
#main {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: min-content min-content 1fr min-content;
grid-template-areas: 'header' 'headline' 'main' '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% 0.5em auto;
grid-template-rows: 60px;
grid-template-areas: 'hd1-left hd1-middle hd1-right';
width: 100%;
margin: 0 auto;
}
aside {
grid-area: sidebar;
margin: 15px 15px;
display: contents;
}
#headline {
grid-area: headline;
margin: 20px 0 20px 0;
text-align: center;
padding: 0 5px;
}
main {
grid-area: main;
margin: 15px 15px;
}
footer {
grid-area: footer;
margin: 15px;
text-align: right;
align-items: end;
}
header .hd1-left {
display: flex;
flex-direction: row;
justify-content: space-between;
-webkit-justify-content: space-between;
grid-area: hd1-left;
text-align: right;
padding-right: 20px;
padding-left: 15px;
align-items: center;
-webkit-align-items: center;
gap: 10px;
}
.hd1-left-menu {
display: flex;
gap: 10px;
justify-self: center;
align-self: center;
cursor: pointer;
padding: 10px 5px;
margin: -10px -5px;
color: var(--dbp-content);
align-items: baseline;
background: none;
border: none;
font: inherit;
}
.hd1-left-menu:focus-visible {
outline: 2px solid var(--dbp-accent);
outline-offset: 2px;
}
.hd1-left-switches {
display: flex;
min-width: 60px;
justify-content: space-between;
}
header .hd1-middle {
grid-area: hd1-middle;
background-color: var(--dbp-content);
background: linear-gradient(
180deg,
var(--dbp-content) 0%,
var(--dbp-content) 85%,
rgba(0, 0, 0, 0) 90%
);
width: 0.1em;
}
header .hd1-right {
grid-area: hd1-right;
display: flex;
justify-content: space-between;
-webkit-justify-content: space-between;
padding: 0 10px 0 10px;
min-width: 0;
align-items: center;
-webkit-align-items: center;
}
header .hd1-right .auth-button {
min-width: 0;
}
header .hd1-right .logo {
height: 100%;
overflow: hidden;
flex-grow: 1;
}
.default-logo {
display: flex;
justify-content: end;
height: 100%;
}
header a {
color: var(--dbp-content);
display: inline;
}
aside ul.menu,
footer ul.menu {
list-style: none;
max-height: calc(100vh - 30px);
overflow-y: auto;
}
footer {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
}
footer > *,
footer slot > * {
margin: 0.5em 0 0 1em;
}
footer a {
border-bottom: var(--dbp-border);
padding: 0;
}
footer a:hover {
color: var(--dbp-hover-color, var(--dbp-content));
background-color: var(--dbp-hover-background-color);
border-color: var(--dbp-hover-color, var(--dbp-content));
}
/* We don't allow 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: var(--dbp-content);
display: block;
padding-right: 13px;
word-break: break-word;
}
.menu a:hover {
color: var(--dbp-hover-color, var(--dbp-content));
background-color: var(--dbp-hover-background-color);
}
.menu a.selected {
border-left: 3px solid var(--dbp-accent);
font-weight: bolder;
padding-left: 0.5em;
padding-right: 0.3em;
}
aside h2.subtitle {
grid-area: headline;
justify-self: center;
display: inline-block;
cursor: pointer;
}
aside .subtitle {
display: none;
color: var(--dbp-content);
font-size: 1.25rem;
font-weight: 300;
line-height: 1.25;
cursor: pointer;
text-align: center;
}
ul.menu {
display: none;
}
ul.menu.is-open {
display: block;
}
a {
transition:
background-color 0.15s ease 0s,
color 0.15s ease 0s;
}
.description {
text-align: left;
margin-bottom: 1rem;
display: none;
}
#dbp-notification {
z-index: 99999;
}
#main.menu-open {
grid-template-columns: 240px minmax(0, 1fr);
grid-template-areas:
'header header'
'headline headline'
'sidebar main'
'footer footer';
}
#main.menu-open aside {
display: block;
}
#main.menu-open aside h2.subtitle {
grid-area: auto;
}
ul.menu:not(.is-open) {
visibility: hidden;
}
/* scroll to top*/
.scroll-top-wrapper {
display: flex;
flex-direction: column;
gap: 2px;
z-index: 1000;
position: fixed;
bottom: 8rem;
right: max(1.5rem, calc((90vw - 1100px) / 2 + 1.5rem));
align-items: center;
}
.scroll-button {
padding: 0.5em;
opacity: 0;
pointer-events: none;
color: var(--dbp-background);
font-size: 1.25em;
transition:
opacity 0.3s ease,
color 0.3s ease;
cursor: pointer;
background-color: var(--dbp-accent);
border: none;
}
.scroll-button.visible {
opacity: 1;
pointer-events: auto;
}
@media (max-width: 1100px) {
.scroll-top-wrapper {
right: 1rem;
}
header .hd1-right .logo {
display: none;
}
header .hd1-right {
padding-left: 0px;
}
header .hd1-left {
padding-right: 10px;
}
#main,
#main.menu-open {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: 'header' 'headline' 'main' 'footer';
}
header {
z-index: 2000;
background-color: var(--dbp-background);
}
aside {
margin: 0;
}
aside ul.menu {
display: block;
position: fixed;
visibility: hidden;
width: 25vw;
min-width: 250px;
left: 0;
right: auto;
top: 0;
max-height: 100dvh;
margin: 3.5rem 0 0 0;
box-sizing: border-box;
transform: translateY(-110%);
transition:
transform 0.28s ease,
box-shadow 0.28s ease,
visibility 0s linear 0.28s;
overflow-y: auto;
padding-block: 0.5rem 1rem;
padding-inline: 0;
z-index: 1500;
background-color: var(--dbp-background);
color: var(--dbp-content);
}
aside ul.menu.is-open {
transform: translateY(0);
visibility: visible;
box-shadow: 0px 0px 0.4em rgba(0, 0, 0, 0.2);
transition-delay: 0s;
}
#main.menu-open {
overflow: hidden;
touch-action: none;
pointer-events: auto;
height: 100vh;
overflow-y: hidden;
}
.menu li {
padding: 7px 10px;
}
.menu a {
padding: 8px;
}
.menu a:active {
opacity: 0.8;
}
}
@media (max-width: 490px) {
aside ul.menu {
width: 100vw;
z-index: 1000;
margin: 3.5rem 0 0 0;
}
header {
z-index: 2500;
}
}
`;
}
_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._onActivityRemoved(this._lastElm);
this._lastElm = undefined;
}
}
this.track('renderActivity', activity.element);
// After it is loaded and registered globally, we get it and register it locally
customElements.whenDefined(activity.element).then(() => {
this.defineScopedElement(activity.element, customElements.get(activity.element));
});
let elm = this.createScopedElement(activity.element);
this._onActivityAdded(elm);
this._lastElm = elm;
return elm;
}
_onShowActivityEvent(e) {
this.switchComponent(e.detail.name);
}
_onActivityAdded(element) {
for (const key of this.topic.attributes || []) {
let value = sessionStorage.getItem('dbp-attr-' + key);
if (value !== null) {
element.setAttribute(key, value);
}
}
this._attrObserver.observe(element, {
attributes: true,
attributeFilter: this.topic.attributes,
});
element.addEventListener('dbp-show-activity', this._onShowActivityEvent);
}
_onActivityRemoved(element) {
this._attrObserver.disconnect();
element.removeEventListener('dbp-show-activity', this._onShowActivityEvent);
}
track(action, message) {
this.sendSetPropertyEvent('analytics-event', {category: action, action: message}, true);
}
// TODO: This maybe could also be done with static html together with unsafeStatic, see https://lit.dev/docs/templates/expressions/#non-literal-statics
// Like in https://github.com/digital-blueprint/cabinet-app/commit/8dde8efab6e65a93026289c7ed6b50c0369a55dd
_renderActivity() {
const act = this.metadata[this.activeView];
if (act === undefined) return html``;
const elm = this._createActivityElement(act);
// add subscriptions for the provider component
if (act.subscribe !== undefined) {
elm.setAttribute('subscribe', act.subscribe);
}
return elm;
}
_updateVisibleRoutes() {
let visibleRoutes = [];
for (let routingName of this.routes) {
const data = this.metadata[routingName];
const requiredRoles = data['required_roles'];
let visible = data['visible'];
// Hide them until the user is logged in and we know the roles of the user
for (let role of requiredRoles) {
if (!this._roles.includes(role)) {
visible = false;
break;
}
}
if (visible) {
visibleRoutes.push(routingName);
}
}
this.visibleRoutes = visibleRoutes;
const event = new CustomEvent('visibility-changed', {
bubbles: false,
cancelable: true,
});
this.dispatchEvent(event);
}
render() {
let i18n = this._i18n;
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});
if (!appHidden) {
// if app is loaded correctly, remove spinner
this.updateComplete.then(() => {
const slot = this.shadowRoot.querySelector('slot:not([name])');
// remove for safari 12 support. safari 13+ supports display: none on slots.
if (slot) slot.remove();
});
}
const prodClassMap = classMap({
hidden: this.env === 'production' || this.env === 'staging' || this.env === '',
});
this.updatePageTitle();
this.updatePageMetaDescription();
// build the menu
let menuTemplates = [];
for (let routingName of this.visibleRoutes) {
let partialState = {
component: routingName,
};
// clear the extra state for everything but the current activity
if (this.activeView !== routingName) {
partialState['extra'] = undefined;
}
menuTemplates.push(html`
<li>
<a
@click="${(e) => this.onMenuItemClick(e)}"
href="${this.router.getPathname(partialState)}"
data-nav
class="${getSelectClasses(routingName)}"
title="${this.metaDataText(routingName, 'description')}">
${this.metaDataText(routingName, 'short_name')}
</a>
</li>
`);
}
let style;
const kc = this.keycloakConfig;
return html`
${style}
<slot class="${slotClassMap}"></slot>
<dbp-auth-keycloak
subscribe="requested-login-status"
lang="${this.lang}"
entry-point-url="${this.entryPointUrl}"
url="${kc.url}"
realm="${kc.realm}"
client-id="${kc.clientId}"
silent-check-sso-redirect-uri="${kc.silentCheckSsoRedirectUri || ''}"
scope="${kc.scope || ''}"
idp-hint="${kc.idpHint || ''}"
?no-check-login-iframe="${kc.noCheckLoginIframe ?? false}"
?force-login="${kc.forceLogin}"
?try-login="${!kc.forceLogin}"></dbp-auth-keycloak>
<dbp-matomo
subscribe="auth,analytics-event"
endpoint="${this.matomoUrl}"
site-id="${this.matomoSiteId}"
git-info="${this.gitInfo}"></dbp-matomo>
<div class="${mainClassMap}" id="root">
<div id="main">
<dbp-notification id="dbp-notification" lang="${this.lang}"></dbp-notification>
<header>
<slot name="header">
<div class="hd1-left">
<button
class="hd1-left-menu"
@click="${this.toggleMenu}"
aria-expanded="false"
aria-label="${this._i18n.t('main-page.menu')}">
<dbp-icon
class="burger-menu-icon"
name="menu"
id="menu-burger-icon"></dbp-icon>
<span class="menu-label">
${this._i18n.t('main-page.menu')}
</span>
</button>
<div class="hd1-left-switches">
<dbp-theme-switcher
subscribe="themes,dark-mode-theme-override"
lang="${this.lang}"></dbp-theme-switcher>
<dbp-language-select
id="lang-select"
lang="${this.lang}"></dbp-language-select>
</div>
</div>
<div class="hd1-middle"></div>
<div class="hd1-right">
<dbp-auth-menu-button
data-testid="dbp-auth-menu-button"
subscribe="auth"
class="auth-button"
lang="${this.lang}"></dbp-auth-menu-button>
<div class="logo">
<slot name="logo">
<dbp-themed>
<div slot="light" class="default-logo">
<svg
id="Ebene_1"
data-name="Ebene 1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 400 400">
<defs>
<style>
.cls-1 {
fill: none;
}
.cls-2 {
clip-path: url(#clippath);
}
.cls-3 {
fill: url(#Unbenannter_Verlauf_24-2);
}
.cls-4 {
fill: #002a60;
}
.cls-5 {
fill: #fff;
}
.cls-6 {
clip-path: url(#clippath-1);
}
.cls-7 {
clip-path: url(#clippath-2);
}
.cls-8 {
opacity: 0.23;
}
.cls-9 {
opacity: 0.43;
}
.cls-10 {
fill: url(#Unbenannter_Verlauf_25);
}
.cls-11 {
fill: url(#Unbenannter_Verlauf_23);
}
.cls-12 {
fill: url(#Unbenannter_Verlauf_24);
}
.cls-13 {
fill: url(#Unbenannter_Verlauf_26);
}
.cls-14 {
fill: url(#Unbenannter_Verlauf_29);
}
.cls-15 {
fill: url(#Unbenannter_Verlauf_27);