feat: add configurable headers and instance branding (#6690)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-25 17:09:47 +02:00
1 parent 97d8390b20
commit 9303dbd284
102 files changed
+12153 -609

No files matched your search

@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import type { CSSProperties, ReactNode } from "react";
import { Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { OnboardingBackdrop } from "./onboarding-backdrop";
@@ -11,17 +11,72 @@ interface OnboardingAuthShellProps {
icon: ReactNode;
children: ReactNode;
footer?: ReactNode;
appName?: string;
showAppName?: boolean;
showAppLogo?: boolean;
primaryColor?: string;
secondaryColor?: string;
wordmarkPrimaryColor?: string;
wordmarkSecondaryColor?: string;
logoImageUrl?: string;
backgroundImageUrl?: string;
backgroundOverlay?: number;
radius?: "xs" | "sm" | "md" | "lg" | "xl";
}
export const OnboardingAuthShell = ({ title, description, icon, children, footer }: OnboardingAuthShellProps) => (
<main className={classes.page}>
export const OnboardingAuthShell = ({
title,
description,
icon,
children,
footer,
appName,
showAppName = false,
showAppLogo = false,
primaryColor,
secondaryColor,
wordmarkPrimaryColor,
wordmarkSecondaryColor,
logoImageUrl,
backgroundImageUrl,
backgroundOverlay = 0.55,
radius = "lg",
}: OnboardingAuthShellProps) => (
<main
className={classes.page}
data-background-image={backgroundImageUrl ? true : undefined}
style={
{
"--studio-glow-color": primaryColor,
"--studio-secondary-glow-color": secondaryColor,
"--auth-background-overlay": backgroundOverlay,
} as CSSProperties
}
>
{backgroundImageUrl ? (
<div
aria-hidden
className={classes.authBackground}
style={{ backgroundImage: `url(${JSON.stringify(backgroundImageUrl)})` }}
/>
) : null}
<OnboardingBackdrop />
<div className={`${classes.shell} ${classes.authShell}`}>
<Stack className={classes.authLayout} gap="xl" align="center" justify="center">
<div className={classes.authBrand}>
<OnboardingWordmark large />
</div>
<Paper className={`${classes.studio} ${classes.authCard}`} radius="lg" p={{ base: "lg", sm: "xl" }}>
{showAppName || showAppLogo ? (
<div className={classes.authBrand}>
<OnboardingWordmark
large
appName={appName}
showAppName={showAppName}
showAppLogo={showAppLogo}
primaryColor={wordmarkPrimaryColor}
secondaryColor={wordmarkSecondaryColor}
logoImageUrl={logoImageUrl}
/>
</div>
) : null}
<Paper className={`${classes.studio} ${classes.authCard}`} radius={radius} p={{ base: "lg", sm: "xl" }}>
<Stack gap="xl">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon size="xl" radius="lg" variant="light">
@@ -88,6 +88,33 @@
max-width: min(92vw, 42rem);
}
.customBrand {
display: flex;
align-items: center;
justify-content: center;
gap: var(--mantine-spacing-md);
max-width: min(92vw, 42rem);
color: var(--mantine-color-text);
font-size: clamp(2rem, 6vw, 4rem);
font-weight: 750;
line-height: 1;
letter-spacing: -0.04em;
}
.customBrand[data-large="true"] {
font-size: clamp(2.5rem, 8vw, 5.5rem);
}
.customBrandImage {
width: auto;
height: clamp(4rem, 11vw, 7rem);
object-fit: contain;
}
.customBrand[data-large="true"] .customBrandImage {
height: clamp(5rem, 16vw, 9rem);
}
.celebratingWordmark {
animation: wordmarkJump 360ms ease-out;
}
@@ -140,6 +167,25 @@
padding-block: clamp(1rem, 4vw, 3rem) calc(clamp(1rem, 4vw, 3rem) + env(safe-area-inset-bottom));
}
.authBackground {
position: fixed;
z-index: 0;
inset: 0;
background-position: center;
background-size: cover;
}
.authBackground::after {
position: absolute;
inset: 0;
content: "";
background: rgb(0 0 0 / var(--auth-background-overlay, 0.55));
}
.page[data-background-image] .marqueeBackdrop {
opacity: 0.14;
}
.authLayout {
width: min(100%, 36rem);
margin: auto;
@@ -9,6 +9,10 @@ import classes from "./onboarding-studio.module.css";
import { useOnboardingSounds } from "./use-onboarding-sounds";
interface OnboardingWordmarkProps {
appName?: string;
logoImageUrl?: string;
showAppName?: boolean;
showAppLogo?: boolean;
primaryColor?: string;
secondaryColor?: string;
large?: boolean;
@@ -16,7 +20,15 @@ interface OnboardingWordmarkProps {
const defaultWordmarkUrl = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.svg";
export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false }: OnboardingWordmarkProps) => {
export const OnboardingWordmark = ({
appName,
logoImageUrl,
showAppName,
showAppLogo,
primaryColor,
secondaryColor,
large = false,
}: OnboardingWordmarkProps) => {
const [celebrating, setCelebrating] = useState(false);
const sounds = useOnboardingSounds();
const className = `${classes.wordmark} ${classes.onboardingWordmark} ${large ? classes.welcomeWordmark : ""} ${celebrating ? classes.celebratingWordmark : ""}`;
@@ -52,14 +64,36 @@ export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false
}
};
const hasVisibilityControls = showAppName !== undefined || showAppLogo !== undefined;
const resolvedAppName = appName?.trim() || "Homarr";
const shouldRenderDefaultWordmark = showAppLogo && showAppName && !logoImageUrl && resolvedAppName === "Homarr";
if (hasVisibilityControls && !shouldRenderDefaultWordmark) {
const visibleLogoImageUrl = showAppLogo ? (logoImageUrl ?? "/logo/logo.png") : undefined;
if (!visibleLogoImageUrl && !showAppName) return null;
return (
<div className={classes.customBrand} data-large={large || undefined} aria-label={resolvedAppName}>
{visibleLogoImageUrl ? (
<img
className={classes.customBrandImage}
src={visibleLogoImageUrl}
alt={showAppName ? "" : resolvedAppName}
/>
) : null}
{showAppName ? <span>{resolvedAppName}</span> : null}
</div>
);
}
if (primaryColor === undefined && secondaryColor === undefined) {
return <img src={defaultWordmarkUrl} alt="Homarr" width={1477} height={1054} className={className} />;
return <img src={defaultWordmarkUrl} alt={resolvedAppName} width={1477} height={1054} className={className} />;
}
return (
<HomarrWordmarkLight
role="img"
aria-label="Homarr"
aria-label={resolvedAppName}
style={colors}
className={className}
onMouseEnter={(event) => celebrate(event.currentTarget)}
+24 -33
View File
@@ -34,7 +34,6 @@ import {
UnstyledButton,
useMantineColorScheme,
} from "@mantine/core";
import type { MantineSize } from "@mantine/core";
import {
IconAdjustments,
IconAlertCircle,
@@ -91,7 +90,16 @@ import {
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
import type { SupportedLanguage } from "@homarr/translation";
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import { BoardColorInput, ColorSchemeCombobox, IntegrationAvatar, LanguageCombobox, Link } from "@homarr/ui";
import type { CornerStyle } from "@homarr/ui";
import {
BoardColorInput,
ColorSchemeCombobox,
CornerStylePicker,
cornerStyleValues,
IntegrationAvatar,
LanguageCombobox,
Link,
} from "@homarr/ui";
import { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid";
import type { OnboardingStudioProps } from "./types";
@@ -146,7 +154,6 @@ const sectionDefinitions = [
{ id: "review", icon: IconCheck },
] as const;
const radiusValues = ["xs", "sm", "md", "lg", "xl"] as const;
const initialPrimaryColor = "#fa5252";
const initialSecondaryColor = "#fd7e14";
const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = [];
@@ -176,7 +183,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
const [boardName, setBoardName] = useState(initialBoard?.name ?? "dashboard");
const [primaryColor, setPrimaryColor] = useState(initialPrimaryColor);
const [secondaryColor, setSecondaryColor] = useState(initialSecondaryColor);
const [itemRadius, setItemRadius] = useState<MantineSize>("lg");
const [itemRadius, setItemRadius] = useState<CornerStyle>("lg");
const [columnCount, setColumnCount] = useState(10);
const [leftSidebar, setLeftSidebar] = useState(false);
const [rightSidebar, setRightSidebar] = useState(false);
@@ -797,8 +804,8 @@ interface StudioSectionProps {
setPrimaryColor: (value: string) => void;
secondaryColor: string;
setSecondaryColor: (value: string) => void;
itemRadius: MantineSize;
setItemRadius: (value: MantineSize) => void;
itemRadius: CornerStyle;
setItemRadius: (value: CornerStyle) => void;
columnCount: number;
setColumnCount: (value: number) => void;
leftSidebar: boolean;
@@ -1452,32 +1459,16 @@ const BoardBuilder = (props: StudioSectionProps) => {
</Button>
) : null}
</Stack>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("radius")}
</Text>
<OnboardingFloatingControl
ariaLabel={t("radius")}
value={props.itemRadius}
onChange={props.setItemRadius}
options={radiusValues.map((value) => ({ value, label: value.toUpperCase() }))}
/>
<Group gap="xs" aria-hidden>
{radiusValues.map((radius) => (
<div
key={radius}
style={{
flex: 1,
height: 34,
borderRadius: `var(--mantine-radius-${radius})`,
background: "var(--mantine-color-default)",
border: "1px solid var(--mantine-color-default-border)",
opacity: props.itemRadius === radius ? 1 : 0.55,
}}
/>
))}
</Group>
</Stack>
<CornerStylePicker
label={t("radius")}
value={props.itemRadius}
labels={
Object.fromEntries(
cornerStyleValues.map((cornerStyle) => [cornerStyle, t(`radiusOptions.${cornerStyle}`)]),
) as Record<CornerStyle, string>
}
onChange={props.setItemRadius}
/>
</Stack>
<Stack gap="lg">
@@ -1543,7 +1534,7 @@ const BoardPreview = ({
appCount,
}: {
ariaLabel: string;
itemRadius: MantineSize;
itemRadius: CornerStyle;
baseColumnCount: number;
leftSidebar: boolean;
rightSidebar: boolean;