/** * Handy-Insel Kopfleiste () – zentrale Navigation für alle Tools. * * Einbindung in ein Tool (eine Zeile, am besten im ): * * * Das Skript fügt die Leiste automatisch oben in ein. Wer sie selbst * platzieren will, setzt ins Markup. * Die Höhe steht als CSS-Variable --hi-topbar-height bereit, z.B. für * Layouts mit height: calc(100vh - var(--hi-topbar-height, 0px)). * * Eigene Knöpfe des Tools erscheinen rechts vor dem Avatar, wenn sie als Kind * mit slot="aktionen" in stehen. Kinder mit slot="benutzer" landen * im Benutzer-Menü zwischen Name und Links. * * Vor dem Abmelden feuert die Leiste "hi-abmelden" (bubbles, composed). Das Tool * beendet darin seine eigene Sitzung; mit e.detail.warten(promise) wartet die * Leiste darauf (höchstens 3 s), mit e.preventDefault() übernimmt das Tool die * Weiterleitung selbst. * * Voraussetzung in der CSP des Tools: script-src, connect-src und img-src * müssen https://portal.handy-insel.de erlauben. */ interface NavApp { slug: string; name: string; url: string; iconUrl: string; newTab: boolean; section: string; } interface Navigation { user: { name: string; email: string; initials: string } | null; apps: NavApp[]; links: { portal: string; account: string; logout: string; login: string }; } const HEIGHT = 44; const CACHE_KEY = 'hi-topbar-nav'; const CACHE_MS = 5 * 60 * 1000; const LOGOUT_WAIT_MS = 3000; const script = document.currentScript as HTMLScriptElement | null; const PORTAL = (script?.dataset.portal || (script?.src ? new URL(script.src).origin : 'https://portal.handy-insel.de')).replace(/\/$/, ''); const css = ` :host { all: initial; display: block; position: relative; z-index: 2147483000; height: ${HEIGHT}px; font-family: "IBM Plex Sans", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif; font-size: 14px; color: #fff; --p-bg: #ffffff; --p-text: #211a38; --p-text-3: #8a83a0; --p-border: #e5e0f0; --p-hover: #ede7fa; --p-current: #e3d9f8; --p-accent: #5e35b1; --p-tile: transparent; --p-shadow: 0 8px 40px rgba(45,27,105,.18); } :host([theme="dark"]) { --p-bg: #1c1436; --p-text: #ece8f6; --p-text-3: #7b7396; --p-border: #312551; --p-hover: #2a2048; --p-current: #2f2452; --p-accent: #a88beb; --p-tile: #f4f0fc; --p-shadow: 0 12px 48px rgba(0,0,0,.55); } :host([sticky]) { position: sticky; top: 0; } * { box-sizing: border-box; } .bar { display: flex; align-items: center; gap: 6px; height: ${HEIGHT}px; padding: 0 8px 0 4px; background: #2d1b69; box-shadow: 0 1px 0 rgba(0,0,0,.12); } button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; } a { color: inherit; text-decoration: none; } .icon-btn { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 8px; } .icon-btn:hover, .icon-btn[aria-expanded="true"] { background: rgba(255,255,255,.14); } .icon-btn:focus-visible, a:focus-visible { outline: 2px solid #cbbdf7; outline-offset: 1px; } .brand { display: flex; align-items: center; gap: 8px; padding: 4px 8px 4px 2px; border-radius: 8px; white-space: nowrap; } .brand:hover { background: rgba(255,255,255,.08); } .brand img { width: 24px; height: 24px; border-radius: 50%; background: #fff; } .brand b { font-weight: 700; } .app-name { opacity: .85; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } .sep { width: 1px; height: 18px; background: rgba(255,255,255,.3); margin: 0 4px; } .spacer { flex: 1; } ::slotted(*) { flex: none; margin: 0 2px; } .avatar { width: 30px; height: 30px; border-radius: 50%; background: #fff; color: #2d1b69; font-size: 12px; font-weight: 700; display: grid; place-items: center; } .panel { position: absolute; top: ${HEIGHT + 6}px; background: var(--p-bg); color: var(--p-text); border-radius: 12px; border: 1px solid var(--p-border); box-shadow: var(--p-shadow); overflow: hidden; } .panel[hidden] { display: none; } .apps { left: 6px; width: 344px; max-width: calc(100vw - 12px); max-height: calc(100vh - 60px); overflow-y: auto; padding: 12px; } .apps h3 { margin: 4px 6px 8px; font-size: 12px; font-weight: 600; color: var(--p-text-3); text-transform: uppercase; letter-spacing: .04em; } .grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; margin-bottom: 8px; } .tile { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 4px; border-radius: 10px; text-align: center; font-size: 12.5px; font-weight: 500; line-height: 1.2; overflow-wrap: anywhere; hyphens: auto; } .tile:hover { background: var(--p-hover); } .tile[aria-current="page"] { background: var(--p-current); } .tile img { width: 36px; height: 36px; padding: 2px; border-radius: 8px; background: var(--p-tile); } .footer { display: block; margin: 4px -12px -12px; padding: 12px 18px; border-top: 1px solid var(--p-border); font-weight: 600; color: var(--p-accent); } .footer:hover { background: var(--p-hover); } .hint { margin: 6px; font-size: 13px; line-height: 1.45; color: var(--p-text); } .hint a { color: var(--p-accent); font-weight: 600; } .user { right: 6px; width: 260px; } .user .who { padding: 12px 16px; border-bottom: 1px solid var(--p-border); } .user .who div:first-child { font-weight: 600; } .user .who div:last-child { font-size: 13px; color: var(--p-text-3); overflow: hidden; text-overflow: ellipsis; } slot[name="benutzer"] { display: block; border-bottom: 1px solid var(--p-border); } .avatar .person { width: 20px; height: 20px; } .user a { display: block; padding: 10px 16px; font-size: 14px; } .user a:hover { background: var(--p-hover); } @media (max-width: 480px) { .brand span { display: none; } } `; const WAFFLE = ''; const PERSON = ''; function esc(s: string): string { return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); } function readCache(): Navigation | null { try { const raw = sessionStorage.getItem(CACHE_KEY); if (!raw) return null; const { at, nav } = JSON.parse(raw) as { at: number; nav: Navigation }; return Date.now() - at < CACHE_MS ? nav : null; } catch { return null; } } function writeCache(nav: Navigation | null): void { try { if (nav?.user) sessionStorage.setItem(CACHE_KEY, JSON.stringify({ at: Date.now(), nav })); else sessionStorage.removeItem(CACHE_KEY); } catch { /* Speicher gesperrt – dann eben ohne Cache */ } } class HiTopbar extends HTMLElement { private root = this.attachShadow({ mode: 'open' }); private nav: Navigation | null = null; private open: 'apps' | 'user' | null = null; private themeObserver?: MutationObserver; private childObserver = new MutationObserver(() => { if (this.hasSlottedUserItems() !== this.renderedSlottedUserItems) this.render(); }); private renderedSlottedUserItems = false; private hasSlottedUserItems(): boolean { return !!this.querySelector(':scope > [slot="benutzer"]'); } private darkQuery = window.matchMedia('(prefers-color-scheme: dark)'); /** Folgt dem Farbschema des Tools: data-theme="dark" (VVL, Portal), Klasse "dark" (Tailwind) oder System. */ private syncTheme = () => { const html = document.documentElement; const attr = html.getAttribute('data-theme'); const dark = attr ? attr === 'dark' : html.classList.contains('dark') || this.darkQuery.matches; if (dark) this.setAttribute('theme', 'dark'); else this.removeAttribute('theme'); }; connectedCallback(): void { document.documentElement.style.setProperty('--hi-topbar-height', `${HEIGHT}px`); this.syncTheme(); this.themeObserver = new MutationObserver(this.syncTheme); this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] }); this.darkQuery.addEventListener('change', this.syncTheme); // Das Benutzer-Menü gibt es auch ohne Portal-Sitzung, sobald das Tool etwas in slot="benutzer" stellt. this.childObserver.observe(this, { childList: true }); this.addEventListener('click', this.onSlotClick); this.nav = readCache(); this.render(); void this.load(); document.addEventListener('mousedown', this.onOutside); document.addEventListener('keydown', this.onKey); } disconnectedCallback(): void { this.themeObserver?.disconnect(); this.childObserver.disconnect(); this.removeEventListener('click', this.onSlotClick); this.darkQuery.removeEventListener('change', this.syncTheme); document.removeEventListener('mousedown', this.onOutside); document.removeEventListener('keydown', this.onKey); } private get appSlug(): string { return this.getAttribute('app') || script?.dataset.app || ''; } private async load(): Promise { try { const res = await fetch(`${PORTAL}/api/navigation`, { credentials: 'include' }); const nav = (await res.json()) as Navigation; this.nav = nav; writeCache(nav); } catch { // Portal nicht erreichbar: Leiste bleibt mit Link zum Portal nutzbar. if (!this.nav) this.nav = null; } this.render(); } private onOutside = (e: MouseEvent) => { if (this.open && !e.composedPath().includes(this)) this.toggle(null); }; private onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && this.open) this.toggle(null); }; /** Ein Link im Benutzer-Steckplatz schließt das Menü, auch wenn das Tool clientseitig navigiert. */ private onSlotClick = (e: MouseEvent) => { const link = (e.target as Element | null)?.closest?.('a'); if (this.open === 'user' && link?.closest('[slot="benutzer"]')) this.toggle(null); }; private toggle(which: 'apps' | 'user' | null): void { this.open = this.open === which ? null : which; this.render(); } /** Erst das Tool abmelden lassen (Ereignis "hi-abmelden"), dann Portal und Authentik. */ private async logout(href: string): Promise { const pending: Promise[] = []; const event = new CustomEvent('hi-abmelden', { bubbles: true, composed: true, cancelable: true, detail: { warten: (p: Promise) => void pending.push(p) }, }); this.dispatchEvent(event); if (pending.length) { await Promise.race([Promise.allSettled(pending), new Promise((r) => setTimeout(r, LOGOUT_WAIT_MS))]); } if (event.defaultPrevented) return; // Immer über das Portal, auch ohne Portal-Sitzung: nur so endet auch die Authentik-Sitzung. // Danach geht es zurück auf diese Seite, die dann die Anmeldung des Tools zeigt. location.href = `${href}?returnTo=${encodeURIComponent(location.href)}`; } private render(): void { const nav = this.nav; const links = nav?.links ?? { portal: `${PORTAL}/`, login: `${PORTAL}/auth/login`, logout: `${PORTAL}/auth/logout`, account: '', }; const current = nav?.apps.find((a) => a.slug === this.appSlug); const appName = current?.name || this.getAttribute('app-name') || ''; const loginUrl = `${links.login}?returnTo=${encodeURIComponent(location.href)}`; const tile = (a: NavApp) => `${esc(a.name)}`; const sections = new Map(); for (const a of nav?.apps ?? []) sections.set(a.section, [...(sections.get(a.section) ?? []), a]); const tiles = [...sections] .map(([title, apps]) => `

${esc(title)}

${apps.map(tile).join('')}
`) .join(''); const appsPanel = nav?.user ? `${tiles}Zum Portal →` : `

Deine Anwendungen erscheinen hier, sobald du im Portal angemeldet bist.
Jetzt anmelden

`; const user = nav?.user; this.renderedSlottedUserItems = this.hasSlottedUserItems(); const hasUserMenu = !!user || this.renderedSlottedUserItems; this.root.innerHTML = `
${appsPanel}
${ hasUserMenu ? `
${user ? `
${esc(user.name)}
${esc(user.email)}
` : ''} Portal ${links.account ? `Konto & Passwort` : ''} Abmelden
` : '' }`; this.root.querySelectorAll('[data-toggle]').forEach((btn) => btn.addEventListener('click', () => this.toggle(btn.dataset.toggle as 'apps' | 'user')), ); this.root.querySelector('[data-logout]')?.addEventListener('click', (e) => { e.preventDefault(); void this.logout(links.logout); }); } } if (!customElements.get('hi-topbar')) customElements.define('hi-topbar', HiTopbar); function autoInsert(): void { if (script?.dataset.auto === 'false' || document.querySelector('hi-topbar')) return; const bar = document.createElement('hi-topbar'); if (script?.dataset.sticky !== undefined) bar.setAttribute('sticky', ''); document.body.prepend(bar); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoInsert); else autoInsert();