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
+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"
}
}