feat: add configurable headers and instance branding (#6690)
This commit is contained in:
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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user