Create module element-web-module-banner

This commit is contained in:
Michael Telatynski
2025-05-08 12:06:05 +01:00
parent 995bc51e93
commit 06796a0a9d
21 changed files with 1081 additions and 1 deletions
+45
View File
@@ -0,0 +1,45 @@
# @element-hq/element-web-module-banner
Banner module for Element Web.
Allows rendering a top bar with slide out left panel menu.
Supports the following configuration options:
| Key | Type | Description |
| ------------- | ------ | ------------------------------------------------------------ |
| logo_url | string | URL to the logo to render in the banner |
| logo_link_url | string | URL to send the user to when clicking the logo in the banner |
| menu | `Menu` | Data to render in the banner menu |
The `Menu` type is fulfilled by the following discriminated union:
### Univention menu
| Key | Type | Description |
| ------- | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
| type | "univention" | The type for this menu config |
| ics_url | string | URL to the UCS Intercom Service, https://docs.software-univention.de/intercom-service/latest/architecture.html#endpoints |
### Static menu
| Key | Type | Description |
| ---------- | ---------------- | ------------------------------------------------------------------------- |
| type | "static" | The type for this menu config |
| logo_url | string, optional | URL to the logo to render in the menu, defaults to banner logo if omitted |
| categories | `[]Category` | Categories to render in the menu |
The `Category` type is fulfilled by the following interface:
| Key | Type | Description |
| ----- | -------- | -------------------------------- |
| name | string | The name of this category |
| links | `[]Link` | Links to render in this category |
The `Link` type is fulfilled by the following interface:
| Key | Type | Description |
| -------- | ---------------- | --------------------------------------- |
| icon_uri | string | URL to the icon to render for this link |
| name | string | The name to render for this link |
| link_url | string | The URL to link to |
| target | string, optional | The `target` to use for this link |
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@element-hq/element-web-module-banner",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "lib/index.js",
"scripts": {
"prepare": "vite build",
"lint:ts": "tsc --noEmit",
"test": "echo no tests yet"
},
"devDependencies": {
"@element-hq/element-web-module-api": "^0.2.0",
"@types/node": "^22.10.7",
"@types/react": "^19",
"@vitejs/plugin-react": "^4.3.4",
"react": "^19",
"rollup-plugin-external-globals": "^0.13.0",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vite-plugin-node-polyfills": "^0.23.0"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.6",
"framer-motion": "^12.4.10",
"styled-components": "^6.1.18",
"zod": "^3.24.2"
}
}
+65
View File
@@ -0,0 +1,65 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type FC } from "react";
import styled, { ThemeProvider } from "styled-components";
import { type Api } from "@element-hq/element-web-module-api";
import { type ModuleConfig } from "./config";
import UniventionMenu from "./Univention/Menu";
import Menu from "./Menu";
import { theme } from "./theme";
import Logo from "./Logo.tsx";
const Root = styled.nav`
background-color: ${({ theme }) => theme.compound.color.bgCanvasDefault};
border-bottom: ${({ theme }) => theme.navbar.border};
height: ${({ theme }) => theme.navbar.height};
display: grid;
grid-template-columns: ${({ theme }) => `${theme.navbar.triggerWidth} auto`};
gap: 24px;
`;
const Main = styled.div`
display: flex;
align-items: center;
grid-column: 2;
`;
interface Props {
api: Api;
logoUrl: string;
href: string;
menu: ModuleConfig["menu"];
}
const Banner: FC<Props> = ({ api, logoUrl, href, menu }) => {
let menuJsx;
switch (menu.type) {
case "static": {
menuJsx = <Menu api={api} config={menu} fallbackLogoUrl={logoUrl} />;
break;
}
case "univention": {
menuJsx = <UniventionMenu api={api} config={menu} fallbackLogoUrl={logoUrl} />;
break;
}
}
return (
<ThemeProvider theme={theme}>
<Root>
{menuJsx}
<Main>
<Logo api={api} src={logoUrl} href={href} />
</Main>
</Root>
</ThemeProvider>
);
};
export default Banner;
+39
View File
@@ -0,0 +1,39 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type FC } from "react";
import styled from "styled-components";
import { type Api } from "@element-hq/element-web-module-api";
const Anchor = styled.a`
display: flex;
`;
const Image = styled.img`
height: ${({ theme }) => theme.navbar.logoHeight};
align-self: center;
`;
interface Props {
api: Api;
src: string;
href?: string;
}
const Logo: FC<Props> = ({ api, src, href }) => {
const img = <Image alt={api.i18n.translate("Portal logo")} src={src} />;
if (!href) return img;
return (
<Anchor aria-label={api.i18n.translate("Show portal")} href={href}>
{img}
</Anchor>
);
};
export default Logo;
+207
View File
@@ -0,0 +1,207 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type FC, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import * as Dialog from "@radix-ui/react-dialog";
import styled from "styled-components";
import { StaticConfig } from "./config";
import { theme } from "./theme";
import type { Api } from "@element-hq/element-web-module-api";
import Logo from "./Logo.tsx";
const Sidebar = styled(motion.div)`
padding: 16px 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
overflow: auto;
position: fixed;
left: 0;
top: 0;
height: 100%;
width: ${({ theme }): string => theme.menu.width};
background: white;
border-top-right-radius: 16px;
border-bottom-right-radius: 16px;
`;
const SidebarHeading = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
`;
const Launcher = styled.button`
align-items: center;
border: none;
color: ${({ theme }) => theme.compound.color.textPrimary};
cursor: pointer;
display: flex;
&:hover,
&:focus,
&[data-expanded="true"] {
background-color: ${({ theme }): string => theme.color.accent};
color: #ffffff;
}
svg {
margin: auto;
}
`;
const CloseButton = styled.button`
/* Reset button styles */
appearance: none;
background: none;
border: none;
padding: 0;
margin: 0;
height: 32px;
width: 32px;
cursor: pointer;
border-radius: 8px;
&:hover,
&:focus {
background-color: rgba(238, 239, 242, 1);
}
`;
const CategoryHeading = styled.h2`
font-weight: 700;
font-size: 12px;
color: #203257;
margin-top: 16px;
margin-bottom: 8px;
`;
const LinkButton = styled.a`
font-size: 14px;
color: #000000;
font-weight: 500;
display: flex;
border-radius: 8px;
padding: 8px;
align-items: center;
&:hover {
background-color: #eeeff2;
}
`;
const LinkLogo = styled.img`
height: 24px;
width: 24px;
border-radius: 3px;
border: 1px solid #eeeff2;
margin-right: 8px;
background-color: #ffffff;
`;
const Overlay = styled(motion.div)`
background-color: rgba(238, 239, 242, 0.5);
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
`;
interface Props {
api: Api;
config: StaticConfig;
fallbackLogoUrl: string;
}
const WIDTH = parseInt(theme.menu.width.slice(0, -2), 10);
const Category: FC<{
data: StaticConfig["categories"][number];
}> = ({ data }) => {
return (
<div>
<CategoryHeading>{data.name}</CategoryHeading>
{data.links.map((link) => (
<LinkButton key={link.link_url} href={link.link_url} target={link.target ?? "_blank"}>
<LinkLogo src={link.icon_uri} role="presentation" /> {link.name}
</LinkButton>
))}
</div>
);
};
const Menu: FC<Props> = ({ api, config, fallbackLogoUrl }) => {
const [open, setOpen] = useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<Launcher aria-haspopup={true} aria-expanded={open} aria-label={api.i18n.translate("Show menu")}>
<svg fill="currentColor" height="16" width="16">
<path d="M0 4h4V0H0v4Zm6 12h4v-4H6v4Zm-6 0h4v-4H0v4Zm0-6h4V6H0v4Zm6 0h4V6H6v4Zm6-10v4h4V0h-4ZM6 4h4V0H6v4Zm6 6h4V6h-4v4Zm0 6h4v-4h-4v4Z" />
</svg>
</Launcher>
</Dialog.Trigger>
<AnimatePresence>
{open && (
<Dialog.Portal forceMount>
<Dialog.Overlay asChild>
<Overlay
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
data-testid="dialog-overlay"
/>
</Dialog.Overlay>
<Dialog.Content asChild>
<Sidebar
initial={{ x: -WIDTH }}
animate={{ x: 0 }}
exit={{ x: -WIDTH }}
transition={{ type: "tween", ease: "easeInOut", duration: 0.3 }}
aria-label={api.i18n.translate("Menu")}
>
<SidebarHeading>
<Logo api={api} src={config.logo_url ?? fallbackLogoUrl} />
<Dialog.Close asChild>
<CloseButton
aria-label={api.i18n.translate("Close menu")}
onClick={() => setOpen(false)}
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.04167 13.9999L6 12.9583L8.9375 9.99992L6 7.06242L7.04167 6.02075L10 8.95825L12.9375 6.02075L13.9792 7.06242L11.0417 9.99992L13.9792 12.9583L12.9375 13.9999L10 11.0624L7.04167 13.9999Z"
fill="currentColor"
/>
</svg>
</CloseButton>
</Dialog.Close>
</SidebarHeading>
{config.categories.map((category) => (
<Category key={category.name} data={category} />
))}
</Sidebar>
</Dialog.Content>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
);
};
export default Menu;
@@ -0,0 +1,50 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { FC, useEffect, useState } from "react";
import type { Api } from "@element-hq/element-web-module-api";
import { StaticConfig, UniventionConfig } from "../config";
import StaticMenu from "../Menu";
import { fetchNavigation } from "./navigation";
import SilentLogin from "./SilentLogin";
interface Props {
api: Api;
config: UniventionConfig;
fallbackLogoUrl: string;
}
const Menu: FC<Props> = ({ api, config, fallbackLogoUrl }) => {
const [loggedIn, setLoggedIn] = useState(false);
const [data, setData] = useState<StaticConfig | null>();
const language = api.i18n.language.toLowerCase().startsWith("de") ? "de-DE" : "en";
useEffect(() => {
let discard = false;
setData(null);
fetchNavigation(config.ics_url, language).then((data) => {
if (discard) return;
setData(data);
});
return (): void => {
discard = true;
};
}, [config, language, loggedIn]);
if (!loggedIn) {
return <SilentLogin onLoggedIn={setLoggedIn} icsUrl={config.ics_url} />;
}
if (data) {
return <StaticMenu api={api} config={data} fallbackLogoUrl={fallbackLogoUrl} />;
}
return <div />;
};
export default Menu;
@@ -0,0 +1,41 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { FC, useEffect } from "react";
import styled from "styled-components";
const HiddenIFrame = styled.iframe`
display: none;
`;
interface Props {
icsUrl: string;
onLoggedIn(success: boolean): void;
}
const SilentLogin: FC<Props> = ({ onLoggedIn, icsUrl }) => {
const url = new URL("silent", icsUrl);
useEffect(() => {
const listener = (event: MessageEvent): void => {
if (event.origin === url.origin && typeof event.data === "object" && event.data["loggedIn"] === true) {
onLoggedIn(true);
}
};
window.addEventListener("message", listener);
return (): void => {
window.removeEventListener("message", listener);
};
}, [onLoggedIn, url.origin]);
// TODO title?
return <HiddenIFrame src={url.href} title="Silent Login" />;
};
export default SilentLogin;
@@ -0,0 +1,86 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { z } from "zod";
import { StaticConfig } from "../config";
/**
* Univention Central Navigation API
* https://docs.software-univention.de/nubus-kubernetes-customization/1.x/en/api/central-navigation.html
*/
export const UniventionCentralNavigation = z.object({
categories: z.array(
z.object({
identifier: z.string(),
display_name: z.string(),
entries: z.array(
z.object({
/**
* A unique identifier for the navigation item.
*/
identifier: z.string(),
/**
* The URL to the icon in SVG format.
*/
icon_url: z.string(),
/**
* The label of the link.
*/
display_name: z.string(),
/**
* The destination URL of the link.
*/
link: z.string(),
/**
* The browsing context in which the browser opens the link.
* Corresponds to the `target` property of `<a>` tags in HTML.
*/
target: z.string(),
/**
* Its usually empty.
*/
keywords: z.object({}).optional(),
}),
),
}),
),
});
type UniventionCentralNavigation = z.infer<typeof UniventionCentralNavigation>;
function navigationToConfig(navigation: UniventionCentralNavigation): StaticConfig {
return {
type: "static",
categories: navigation.categories.map((category) => ({
name: category.display_name,
links: category.entries.map((entry) => ({
icon_uri: entry.icon_url,
name: entry.display_name,
link_url: entry.link,
target: entry.target,
})),
})),
};
}
export async function fetchNavigation(icsUrl: string, language: string): Promise<StaticConfig> {
const url = new URL("navigation.json", icsUrl);
url.search = `?language=${language}`;
const response = await fetch(url, {
credentials: "include",
});
if (!response.ok) {
throw new Error(`Failed to fetch navigation: ${response.status}`);
}
const data = await response.json();
const config = await UniventionCentralNavigation.parseAsync(data);
return navigationToConfig(config);
}
+93
View File
@@ -0,0 +1,93 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { z } from "zod";
const StaticConfig = z.object({
type: z.literal("static"),
/**
* Alternative logo URL to display in the popover menu.
* Will use the main logo url if omitted.
*/
logo_url: z.string().url().optional(),
/**
* Categories of links to display in the menu.
*/
categories: z.array(
z.object({
/**
* The category display name
*/
name: z.string(),
/**
* List of links to display in the category
*/
links: z.array(
z.object({
/**
* The URL to the icon.
*/
icon_uri: z.string().url(),
/**
* The label of the link.
*/
name: z.string(),
/**
* The destination URL of the link.
*/
link_url: z.string().url(),
/**
* The browsing context in which the browser opens the link.
*/
target: z.string().optional(),
}),
),
}),
),
});
export type StaticConfig = z.infer<typeof StaticConfig>;
const UniventionConfig = z.object({
type: z.literal("univention"),
/**
* Base URL to an Intercom Service
* https://docs.software-univention.de/intercom-service/latest/architecture.html#endpoints
*/
ics_url: z.string().url(),
});
export type UniventionConfig = z.infer<typeof UniventionConfig>;
export const ModuleConfig = z.object({
/**
* The URL of the portal logo.svg file.
* @example `https://example.com/logo.svg`
*/
logo_url: z.string().url(),
/**
* The URL of the portal.
* @example `https://example.com`
*/
logo_link_url: z.string().url(),
menu: z.discriminatedUnion("type", [StaticConfig, UniventionConfig]),
});
export type ModuleConfig = z.infer<typeof ModuleConfig>;
export const CONFIG_KEY = "io.element.element-web-modules.banner";
declare module "@element-hq/element-web-module-api" {
export interface Config {
[CONFIG_KEY]: ModuleConfig;
}
}
+46
View File
@@ -0,0 +1,46 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api";
import Translations from "./translations.json";
import { ModuleConfig, CONFIG_KEY } from "./config";
import Banner from "./Banner";
import { name as ModuleName } from "../package.json";
class TopBarModule implements Module {
public static readonly moduleApiVersion = "^0.2.0";
private config?: ModuleConfig;
public constructor(private api: Api) {}
public async load(): Promise<void> {
this.api.i18n.register(Translations);
try {
this.config = ModuleConfig.parse(this.api.config.get(CONFIG_KEY));
} catch (e) {
console.error("Failed to init module", e);
throw new Error(`Errors in module configuration for "${ModuleName}"`);
}
const div = document.createElement("div");
this.api.rootNode.before(div);
const root = this.api.createRoot(div);
root.render(
<Banner
api={this.api}
logoUrl={this.config.logo_url}
href={this.config.logo_link_url}
menu={this.config.menu}
/>,
);
}
}
export default TopBarModule satisfies ModuleFactory;
+43
View File
@@ -0,0 +1,43 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { DefaultTheme } from "styled-components";
const bgCanvasDefault = "var(--cpd-color-bg-canvas-default)";
const textActionAccent = "var(--cpd-color-text-action-accent)";
const textPrimary = "var(--cpd-color-text-primary)";
const iconOnSolidPrimary = "var(--cpd-color-icon-on-solid-primary)";
const bodyMdSemibold = "var(--cpd-font-body-md-semibold)";
export const theme: DefaultTheme = {
compound: {
color: {
bgCanvasDefault,
textActionAccent,
textPrimary,
iconOnSolidPrimary,
},
font: {
bodyMdSemibold,
},
},
color: {
accent: "#571EFA", // primary/700 TODO
},
navbar: {
border: "1px solid #D3D7DE", // TODO
boxShadow: "4px 4px 12px 0 rgba(118, 131, 156, 0.6)",
height: "60px",
triggerWidth: "68px",
logoHeight: "34px",
},
menu: {
// TODO
width: "235px",
},
};
@@ -0,0 +1,22 @@
{
"Portal logo": {
"en": "Portal logo",
"de": "Portal Logo"
},
"Menu": {
"en": "Menu",
"de": "Menü"
},
"Show menu": {
"en": "Show menu",
"de": "Menü anzeigen"
},
"Close menu": {
"en": "Close menu",
"de": "Menü schließen"
},
"Show portal": {
"en": "Show portal",
"de": "Portal anzeigen"
}
}
@@ -0,0 +1,153 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../../../playwright/element-web-test.ts";
import { ModuleConfig } from "../src/config.ts";
test.describe("Banner", () => {
test.use({
displayName: "Timmy",
page: async ({ context, page, moduleDir }, use) => {
for (const path of ["logo.svg", "app1.png", "app2.png"]) {
await context.route(`/${path}*`, async (route) => {
await route.fulfill({ path: `${moduleDir}/tests/fixture/${path}` });
});
}
await context.route("http://localhost:8080/ics/navigation.json*", async (route) => {
await route.fulfill({
path: `${moduleDir}/tests/fixture/navigation.json`,
contentType: "application/json",
});
});
await context.route("http://localhost:8080/ics/silent", async (route) => {
await route.fulfill({ path: `${moduleDir}/tests/fixture/silent/index.html`, contentType: "text/html" });
});
await page.goto("/");
await use(page);
},
});
test("should error if config is missing", { tag: ["@screenshot"] }, async ({ page }) => {
await expect(page.getByText("Your Element is misconfigured")).toBeVisible();
// We don't take a screenshot as we don't want to assert Element's styling, only our own
});
const configs: ModuleConfig[] = [
{
logo_url: "http://localhost:8080/logo.svg",
logo_link_url: "https://example.com/portal",
menu: {
type: "static",
categories: [
{
name: "Applications",
links: [
{
icon_uri: "http://localhost:8080/app1.png",
name: "App 1",
link_url: "https://example.com/app1",
target: "app1",
},
{
icon_uri: "http://localhost:8080/app2.png",
name: "Riot",
link_url: "https://riot.im/app",
target: "riot-im",
},
],
},
{
name: "Links",
links: [
{
icon_uri: "http://localhost:8080/app1.png",
name: "Link",
link_url: "https://example.com/link1",
},
],
},
],
},
},
{
logo_url: "http://localhost:8080/logo.svg",
logo_link_url: "https://example.com/portal",
menu: {
type: "univention",
ics_url: "http://localhost:8080/ics/",
},
},
];
for (const config of configs) {
const type = config.menu.type;
test.describe(`${type} config`, () => {
test.use({
config: {
"io.element.element-web-modules.banner": config,
},
});
test("should render", { tag: ["@screenshot"] }, async ({ page, axe }) => {
await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
await expect(page.getByLabel("Show portal")).toHaveAttribute("href", "https://example.com/portal");
const nav = page.locator("nav");
if (type === "univention") {
await expect(nav).toMatchScreenshot(`${type}_nav_loading.png`);
// The stub silent html doesn't seem to work in Playwright so send the postMessage manually
await page.evaluate(() => {
window.postMessage({
loggedIn: true,
});
});
}
const trigger = page.getByLabel("Show menu");
await expect(trigger).toBeVisible();
// Assert the banner looks as we expect
await expect(nav).toMatchAriaSnapshot();
await expect(nav).toMatchScreenshot(`${type}_nav.png`);
// Check hover styles
await trigger.hover();
await expect(nav).toMatchScreenshot(`${type}_nav_hover.png`);
await test.step("open menu", async () => {
await trigger.click();
const app1 = page.getByText("App 1");
await expect(app1).toHaveAttribute("href", "https://example.com/app1");
await app1.hover();
// Assert the sidebar looks as we expect
const sidebar = page.getByRole("dialog");
await expect(sidebar).toMatchAriaSnapshot();
await expect(page).toMatchScreenshot(`${type}_menu.png`, {
// We exclude this as we don't want to assert Element's styling, only our own
css: `
#matrixchat {
opacity: 0;
background: orchid;
}
`,
});
});
await test.step("close menu", async () => {
await expect(axe).toHaveNoViolations();
// Assert it closes by clicking the overlay
await page.getByTestId("dialog-overlay").click();
await expect(page.getByRole("dialog")).not.toBeVisible();
});
});
});
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 13.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 14576) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px"
width="789.322px" height="336.807px" viewBox="0 0 789.322 336.807" enable-background="new 0 0 789.322 336.807"
xml:space="preserve">
<path d="M8.876,7.71v321.386h23.13v7.711H0V0h32.006v7.71H8.876z"/>
<path d="M97.989,109.594v16.264h0.463c4.338-6.191,9.563-10.998,15.684-14.406c6.117-3.402,13.129-5.11,21.027-5.11
c7.588,0,14.521,1.475,20.793,4.415c6.274,2.945,11.038,8.131,14.291,15.567c3.56-5.265,8.4-9.913,14.521-13.94
c6.117-4.025,13.358-6.042,21.724-6.042c6.351,0,12.234,0.776,17.66,2.325c5.418,1.549,10.065,4.027,13.938,7.434
c3.869,3.41,6.889,7.863,9.062,13.357c2.167,5.504,3.253,12.122,3.253,19.869v80.385h-32.993v-68.074
c0-4.025-0.154-7.82-0.465-11.385c-0.313-3.56-1.161-6.656-2.555-9.293c-1.395-2.631-3.45-4.724-6.157-6.274
c-2.711-1.543-6.391-2.322-11.037-2.322s-8.403,0.896-11.269,2.671c-2.868,1.784-5.112,4.109-6.737,6.971
c-1.626,2.869-2.711,6.12-3.252,9.762c-0.545,3.638-0.814,7.318-0.814,11.035v66.91h-32.991v-67.375c0-3.562-0.081-7.087-0.23-10.57
c-0.158-3.487-0.814-6.7-1.978-9.645c-1.162-2.94-3.099-5.304-5.809-7.088c-2.711-1.775-6.699-2.671-11.965-2.671
c-1.551,0-3.603,0.349-6.156,1.048c-2.556,0.697-5.036,2.016-7.435,3.949c-2.404,1.938-4.454,4.726-6.158,8.363
c-1.705,3.642-2.556,8.402-2.556,14.287v69.701h-32.99V109.594H97.989z"/>
<path d="M271.545,127.254c3.405-5.113,7.744-9.215,13.012-12.316c5.264-3.097,11.186-5.303,17.771-6.621
c6.582-1.315,13.205-1.976,19.865-1.976c6.042,0,12.158,0.428,18.354,1.277c6.195,0.855,11.85,2.522,16.962,4.997
c5.111,2.477,9.292,5.926,12.546,10.338c3.253,4.414,4.879,10.262,4.879,17.543v62.494c0,5.428,0.31,10.611,0.931,15.567
c0.615,4.959,1.701,8.676,3.251,11.153h-33.455c-0.621-1.86-1.126-3.755-1.511-5.693c-0.39-1.933-0.661-3.908-0.813-5.923
c-5.267,5.422-11.465,9.217-18.585,11.386c-7.127,2.163-14.407,3.251-21.842,3.251c-5.733,0-11.077-0.698-16.033-2.09
c-4.958-1.395-9.293-3.562-13.01-6.51c-3.718-2.938-6.622-6.656-8.713-11.147s-3.138-9.84-3.138-16.033
c0-6.813,1.199-12.43,3.604-16.84c2.399-4.417,5.495-7.939,9.295-10.575c3.793-2.632,8.129-4.607,13.01-5.923
c4.878-1.315,9.795-2.358,14.752-3.137c4.957-0.772,9.835-1.393,14.638-1.857c4.801-0.466,9.062-1.164,12.779-2.093
c3.718-0.929,6.658-2.282,8.829-4.065c2.165-1.781,3.172-4.375,3.02-7.785c0-3.56-0.58-6.389-1.742-8.479
c-1.161-2.09-2.711-3.719-4.646-4.88c-1.937-1.161-4.183-1.936-6.737-2.325c-2.557-0.382-5.309-0.58-8.248-0.58
c-6.506,0-11.617,1.395-15.335,4.183c-3.716,2.788-5.889,7.437-6.506,13.94h-32.991C266.2,138.793,268.133,132.362,271.545,127.254z
M336.714,173.837c-2.09,0.696-4.337,1.275-6.736,1.741c-2.402,0.465-4.918,0.853-7.551,1.161c-2.635,0.313-5.268,0.698-7.899,1.163
c-2.48,0.461-4.919,1.086-7.317,1.857c-2.404,0.779-4.495,1.822-6.274,3.138c-1.784,1.317-3.216,2.985-4.3,4.994
c-1.085,2.014-1.626,4.571-1.626,7.668c0,2.94,0.541,5.422,1.626,7.431c1.084,2.017,2.558,3.604,4.416,4.765
s4.025,1.976,6.507,2.438c2.475,0.466,5.031,0.698,7.665,0.698c6.505,0,11.537-1.082,15.103-3.253
c3.561-2.166,6.192-4.762,7.899-7.785c1.702-3.019,2.749-6.072,3.137-9.174c0.384-3.097,0.58-5.576,0.58-7.434V170.93
C340.548,172.172,338.806,173.139,336.714,173.837z"/>
<path d="M461.826,109.594v22.072h-24.161v59.479c0,5.573,0.928,9.292,2.788,11.149c1.856,1.859,5.576,2.788,11.152,2.788
c1.859,0,3.638-0.076,5.343-0.232c1.703-0.152,3.33-0.388,4.878-0.696v25.557c-2.788,0.465-5.887,0.773-9.293,0.931
c-3.407,0.149-6.737,0.23-9.99,0.23c-5.111,0-9.953-0.35-14.521-1.048c-4.571-0.695-8.597-2.047-12.081-4.063
c-3.486-2.011-6.236-4.88-8.248-8.597c-2.016-3.714-3.021-8.595-3.021-14.639v-70.859h-19.98v-22.072h19.98V73.582h32.992v36.012
H461.826z"/>
<path d="M508.989,109.594v22.306h0.465c1.546-3.72,3.636-7.163,6.272-10.341c2.634-3.172,5.652-5.885,9.06-8.131
c3.405-2.242,7.047-3.985,10.923-5.228c3.868-1.237,7.898-1.859,12.081-1.859c2.168,0,4.566,0.39,7.202,1.163v30.67
c-1.551-0.312-3.41-0.584-5.576-0.814c-2.17-0.233-4.26-0.35-6.274-0.35c-6.041,0-11.152,1.01-15.332,3.021
c-4.182,2.014-7.55,4.761-10.107,8.247c-2.555,3.487-4.379,7.55-5.462,12.198c-1.083,4.645-1.625,9.682-1.625,15.102v54.133h-32.991
V109.594H508.989z"/>
<path d="M568.931,91.006V63.823h32.994v27.183H568.931z M601.925,109.594v120.117h-32.994V109.594H601.925z"/>
<path d="M619.116,109.594h37.637l21.144,31.365l20.911-31.365h36.476l-39.496,56.226l44.377,63.892h-37.64l-25.093-37.87
l-25.094,37.87H615.4l43.213-63.193L619.116,109.594z"/>
<path d="M780.444,329.096V7.71h-23.13V0h32.008v336.807h-32.008v-7.711H780.444z"/>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,44 @@
{
"categories": [
{
"identifier": "category1",
"display_name": "Category 1",
"entries": [
{
"identifier": "app1",
"display_name": "App 1",
"icon_url": "http://localhost:8080/app1.png",
"link": "https://example.com/app1",
"target": "_blank"
},
{
"identifier": "app2",
"display_name": "App 2",
"icon_url": "http://localhost:8080/app2.png",
"link": "https://example.com/app2",
"target": "_blank"
}
]
},
{
"identifier": "category2",
"display_name": "Category 2",
"entries": [
{
"identifier": "app3",
"display_name": "App 3",
"icon_url": "http://localhost:8080/app1.png",
"link": "https://example.com/app3",
"target": "_blank"
},
{
"identifier": "app4",
"display_name": "App 4",
"icon_url": "http://localhost:8080/app2.png",
"link": "https://example.com/app4",
"target": "_blank"
}
]
}
]
}
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Univention Silent Login</title>
<script type="text/javascript">
window.postMessage({
loggedIn: true,
});
</script>
</head>
<body></body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "lib",
"jsx": "react-jsx"
},
"include": ["src"]
}
+45
View File
@@ -0,0 +1,45 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import externalGlobals from "rollup-plugin-external-globals";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, "src/index.tsx"),
name: "element-web-module-banner",
fileName: "index",
formats: ["es"],
},
outDir: "lib",
target: "esnext",
sourcemap: true,
rollupOptions: {
external: ["react"],
},
},
plugins: [
react(),
nodePolyfills({
include: ["events"],
}),
externalGlobals({
// Reuse React from the host app
react: "window.React",
}),
],
define: {
process: { env: {} },
},
});
+1 -1
View File
@@ -12,7 +12,7 @@
"test": "echo no tests yet"
},
"devDependencies": {
"@element-hq/element-web-module-api": "^0.1.0",
"@element-hq/element-web-module-api": "^0.2.0",
"@nordeck/element-web-guest-module": "^2.0.0",
"@nordeck/element-web-opendesk-module": "^0.5.0",
"@nordeck/element-web-widget-lifecycle-module": "^1.0.1",