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,6 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
import dayjs from "dayjs";
@@ -10,18 +11,23 @@ import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common";
import { colorSchemeCookieKey } from "@homarr/definitions";
import { theme } from "@homarr/ui";
import type { ServerSettings } from "@homarr/server-settings";
import { createBrandTheme } from "~/theme/branding";
export const CustomMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
branding,
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme; branding?: ServerSettings["branding"] }>) => {
const manager = useColorSchemeManager();
const configuredTheme = useMemo(() => (branding ? createBrandTheme(branding) : theme), [branding]);
return (
<DirectionProvider>
<MantineProvider
defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager}
theme={theme}
theme={configuredTheme}
cssVariablesResolver={v8CssVariablesResolver}
>
{children}
@@ -1,11 +1,13 @@
import { notFound } from "next/navigation";
import { IconUserPlus } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema";
import { OnboardingAuthShell } from "@homarr/onboarding";
import { defaultBrandingSettings, getBrandingColorOverrides } from "@homarr/server-settings";
import { getI18n } from "@homarr/translation/server";
import { RegistrationForm } from "./_registration-form";
@@ -20,11 +22,9 @@ interface InviteUsagePageProps {
}
export default async function InviteUsagePage(props: InviteUsagePageProps) {
const searchParams = await props.searchParams;
const params = await props.params;
if (!isProviderEnabled("credentials")) notFound();
const session = await auth();
const [searchParams, params, session] = await Promise.all([props.searchParams, props.params, auth()]);
if (session) notFound();
const invite = await db.query.invites.findFirst({
@@ -45,13 +45,33 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
if (!invite || invite.expirationDate < new Date()) notFound();
const t = await getI18n("user.page.invite");
const [t, serverSettings] = await Promise.all([getI18n("user.page.invite"), getRscServerSettingsAsync()]);
const branding = serverSettings.branding;
const wordmarkColors = getBrandingColorOverrides(branding);
const showCustomAppName = branding.authBranding.showAppName && branding.appName !== defaultBrandingSettings.appName;
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
const title = showCustomAppName ? t("titleWithAppName", { appName: branding.appName }) : t("title");
const defaultDescription = showCustomAppName
? t("subtitleWithAppName", { appName: branding.appName })
: t("subtitle");
const description = showCustomGreeting ? branding.greeting : defaultDescription;
return (
<OnboardingAuthShell
title={t("title")}
description={t("subtitle")}
title={title}
description={description}
icon={<IconUserPlus size={24} />}
appName={branding.appName}
showAppName={branding.authBranding.showAppName}
showAppLogo={branding.authBranding.showLogo}
primaryColor={branding.primaryColor}
secondaryColor={branding.secondaryColor}
wordmarkPrimaryColor={wordmarkColors.primaryColor}
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
logoImageUrl={branding.logoImageUrl ?? undefined}
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
backgroundOverlay={branding.signInBackgroundOverlay}
radius={branding.defaultRadius}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
footer={t("description", { username: invite.creator.name! })}
>
@@ -2,9 +2,11 @@ import { redirect } from "next/navigation";
import { Alert, Code, Text } from "@mantine/core";
import { IconLogin } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { OnboardingAuthShell } from "@homarr/onboarding";
import { getBrandingColorOverrides } from "@homarr/server-settings";
import { getI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
@@ -19,17 +21,35 @@ interface LoginProps {
}
export default async function Login(props: LoginProps) {
const searchParams = await props.searchParams;
const session = await auth();
const [searchParams, session] = await Promise.all([props.searchParams, auth()]);
if (session) {
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
}
const t = await getI18n("user.page.login");
const [t, serverSettings] = await Promise.all([getI18n("user.page.login"), getRscServerSettingsAsync()]);
const branding = serverSettings.branding;
const wordmarkColors = getBrandingColorOverrides(branding);
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
const description = showCustomGreeting ? branding.greeting : t("subtitle");
return (
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
<OnboardingAuthShell
title={t("title")}
description={description}
icon={<IconLogin size={24} />}
appName={branding.appName}
showAppName={branding.authBranding.showAppName}
showAppLogo={branding.authBranding.showLogo}
primaryColor={branding.primaryColor}
secondaryColor={branding.secondaryColor}
wordmarkPrimaryColor={wordmarkColors.primaryColor}
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
logoImageUrl={branding.logoImageUrl ?? undefined}
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
backgroundOverlay={branding.signInBackgroundOverlay}
radius={branding.defaultRadius}
>
{appEnv.DEMO_MODE ? (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light">
<Text size="sm" fw={500}>
@@ -4,6 +4,7 @@ import { notFound, redirect } from "next/navigation";
import { TRPCError } from "@trpc/server";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { makeQueryClient } from "@homarr/api/shared";
import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
@@ -15,12 +16,11 @@ import type { WidgetKind } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata";
import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { ClientBoard } from "./_client";
import { BoardContentHeaderActions } from "./_header-actions";
import { BoardContentEditAction, BoardContentSettingsAction } from "./_header-actions";
const logger = createLogger({ module: "createBoardContentPage" });
const getQueryClient = cache(makeQueryClient);
@@ -36,7 +36,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
@@ -103,18 +104,24 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
try {
const board = await getInitialBoard(await params);
const t = await getI18n("board");
const [board, t, serverSettings] = await Promise.all([
getInitialBoard(await params),
getI18n("board"),
getRscServerSettingsAsync(),
]);
const brandLogo = serverSettings.branding.logoImageUrl ?? "/logo/logo.png";
const brandFavicon = serverSettings.branding.faviconImageUrl ?? brandLogo;
const favicon = !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : brandFavicon;
return {
title: board.metaTitle ?? createMetaTitle(t("content.metaTitle", { boardName: board.name })),
title: board.metaTitle ? { absolute: board.metaTitle } : t("content.metaTitle", { boardName: board.name }),
icons: {
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
icon: favicon,
apple: favicon,
},
appleWebApp: {
startupImage: {
url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png",
url: favicon,
},
},
};
@@ -39,10 +39,9 @@ const loadBoardEditorAsync = async () =>
]);
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const t = useI18n("board");
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) return null;
@@ -50,20 +49,27 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return (
<>
{isEditMode && <BoardEditActions />}
<EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
)}
</>
);
};
export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const board = useRequiredBoard();
const t = useI18n("board");
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess || demoReadOnly) return null;
return (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
);
};
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
@@ -1,30 +1,33 @@
"use client";
import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { colorsTuple, createTheme, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useColorSchemeManager } from "../../_client-providers/mantine";
import { generateColorScale } from "~/theme/branding";
export const BoardMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
const board = useRequiredBoard();
const { branding } = useSettings();
const colorSchemeManager = useColorSchemeManager();
const primaryColor = branding.lockPrimaryColor ? branding.primaryColor : board.primaryColor;
const theme = createTheme({
colors: {
primaryColor: generateColors(board.primaryColor),
secondaryColor: generateColors(board.secondaryColor),
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
primaryColor: generateColorScale(primaryColor),
secondaryColor: generateColorScale(board.secondaryColor),
iconColor: board.iconColor ? generateColorScale(board.iconColor) : colorsTuple("#000000"),
},
primaryColor: "primaryColor",
autoContrast: true,
defaultRadius: "sm",
defaultRadius: branding.defaultRadius,
fontSizes: {
"2xl": rem(24),
"3xl": rem(28),
@@ -43,29 +46,3 @@ export const BoardMantineProvider = ({
</MantineProvider>
);
};
export const generateColors = (hex: string) => {
const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
const rgbaColors = lightnessForColors.map((lightness) => {
if (lightness < 0) {
return lighten(hex, -lightness);
}
return darken(hex, lightness);
});
return rgbaColors.map((color) => {
return (
"#" +
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
color
.split("(")[1]!
.replaceAll(" ", "")
.replace(")", "")
.split(",")
.map((color) => parseInt(color, 10))
.slice(0, 3)
.map((color) => color.toString(16).padStart(2, "0"))
.join("")
);
}) as unknown as MantineColorsTuple;
};
@@ -1,5 +1,6 @@
import { IconHomeOff } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
import { boards } from "@homarr/db/schema";
@@ -7,6 +8,7 @@ import { getI18n } from "@homarr/translation/server";
import type { BoardNotFoundProps } from "~/components/board/not-found";
import { BoardNotFound } from "~/components/board/not-found";
import { homarrLogoPath } from "~/components/layout/logo/constants";
export default async function NotFoundBoardHomePage() {
const boardNotFoundProps = await getPropsAsync();
@@ -19,8 +21,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
const t = await getI18n("board");
if (boardCount === 0) {
const { branding } = await getRscServerSettingsAsync();
return {
icon: { src: "/favicon.ico", alt: "Homarr logo" },
icon: { src: branding.logoImageUrl ?? homarrLogoPath, alt: `${branding.appName} logo` },
title: t("error.noBoard.title"),
description: t("error.noBoard.description"),
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
@@ -22,9 +22,10 @@ import { IconX } from "@tabler/icons-react";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { BoardColorInput } from "@homarr/ui";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card";
import { generateColors } from "../../(content)/_theme";
import { generateColorScale } from "~/theme/branding";
import type { FormValues } from "./_settings-form";
interface Props {
@@ -40,13 +41,19 @@ export const ColorSettingsContent = ({ form }: Props) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const theme = useMantineTheme();
const { branding } = useSettings();
return (
<SectionCard title={tBoard("setting.section.appearance.title")}>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs">
<BoardColorInput label={tBoard("field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
<BoardColorInput
label={tBoard("field.primaryColor.label")}
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
disabled={branding.lockPrimaryColor}
{...form.getInputProps("primaryColor")}
/>
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
@@ -121,7 +128,8 @@ interface ColorsPreviewProps {
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
const theme = useMantineTheme();
const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000");
const colors =
previewColor && hexRegex.test(previewColor) ? generateColorScale(previewColor) : generateColorScale("#000000");
return (
<Group gap={0} wrap="nowrap">
@@ -8,9 +8,9 @@ import { useUpdateBoard } from "@homarr/boards/updater";
import type { UseFormReturnType } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card";
import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types";
import type { FormValues } from "./_settings-form";
@@ -21,20 +21,26 @@ interface Props {
export const GeneralSettingsContent = ({ board, form }: Props) => {
const t = useI18n("board");
const { branding } = useSettings();
const defaultMetaTitle = t("content.metaTitle", { boardName: board.name });
useLogoPreview(form.values.logoImageUrl);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle, defaultMetaTitle);
return (
<SectionCard title={t("setting.section.general.title")}>
<Grid>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput label={t("field.pageTitle.label")} placeholder="Homarr" {...form.getInputProps("pageTitle")} />
<TextInput
label={t("field.pageTitle.label")}
placeholder={branding.appName}
{...form.getInputProps("pageTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("field.metaTitle.label")}
placeholder={createMetaTitle(t("content.metaTitle", { boardName: board.name }))}
placeholder={defaultMetaTitle}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
{...form.getInputProps("metaTitle")}
/>
@@ -43,7 +49,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<IconPicker
{...form.getInputProps("logoImageUrl")}
label={t("field.logoImageUrl.label")}
placeholder="/logo/logo.png"
placeholder={branding.logoImageUrl ?? "/logo/logo.png"}
withAsterisk={false}
/>
</Grid.Col>
@@ -51,7 +57,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<IconPicker
{...form.getInputProps("faviconImageUrl")}
label={t("field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
placeholder={branding.faviconImageUrl ?? branding.logoImageUrl ?? "/logo/logo.png"}
withAsterisk={false}
/>
</Grid.Col>
@@ -72,9 +78,9 @@ const useLogoPreview = (url: string | null) => {
}, [logoDebounced, updateBoard]);
};
const useMetaTitlePreview = (title: string | null) => {
const useMetaTitlePreview = (title: string | null, fallbackTitle: string) => {
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
useDocumentTitle(metaTitleDebounced);
useDocumentTitle(metaTitleDebounced.trim() || fallbackTitle);
return {
isPending: (title ?? "") !== metaTitleDebounced,
@@ -10,10 +10,11 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { homarrLogoPath } from "~/components/layout/logo/constants";
import { SectionCard } from "~/components/manage/section-card";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
@@ -88,6 +89,7 @@ interface BoardSettingsFormProps {
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
const t = useI18n("common");
const tSection = useI18n("board.setting.section");
const { branding } = useSettings();
const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(board);
const saveLayouts = useSaveLayoutsMutation(board);
@@ -142,7 +144,9 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const [, canonicalLayouts] = await Promise.all([
partialSettingsChanged
? savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
updateFavicon(
values.faviconImageUrl ?? branding.faviconImageUrl ?? branding.logoImageUrl ?? homarrLogoPath,
);
})
: Promise.resolve(),
layoutsChanged ? saveLayouts.mutateAsync({ id: board.id, layouts }) : Promise.resolve(null),
@@ -12,7 +12,8 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { ModalProvider } from "@homarr/modals";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { appShellLogoHeight } from "~/components/layout/constants";
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
import { env } from "~/env";
@@ -27,13 +28,17 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" });
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
headerActions?: JSX.Element;
headerBoardEditAction?: JSX.Element;
headerBoardSettingsAction?: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
withTour?: boolean;
}
export const createBoardLayout = <TParams extends Params>({
headerActions,
headerBoardEditAction,
headerBoardSettingsAction,
getInitialBoardAsync: getInitialBoard,
withTour = false,
}: CreateBoardLayoutProps<TParams>) => {
@@ -105,8 +110,11 @@ export const createBoardLayout = <TParams extends Params>({
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
hasNavigation={false}
/>
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
+52 -38
View File
@@ -27,6 +27,7 @@ import { SettingsProvider } from "@homarr/settings";
import { SpotlightProvider } from "@homarr/spotlight";
import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { getI18n } from "@homarr/translation/server";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { Analytics } from "~/components/layout/analytics";
@@ -53,35 +54,45 @@ const fontSans = Inter({
const logger = createLogger({ module: "rootLayout" });
// eslint-disable-next-line no-restricted-syntax
export const generateMetadata = async (): Promise<Metadata> => ({
title: "Homarr",
description:
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
openGraph: {
title: "Homarr Dashboard",
description:
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
url: env.HOMARR_WEBSITE_URL,
siteName: "Homarr",
},
icons: {
icon: "/logo/logo.png",
apple: "/logo/logo.png",
},
appleWebApp: {
title: "Homarr",
capable: true,
startupImage: { url: "/logo/logo.png" },
statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default",
},
});
export const generateMetadata = async (): Promise<Metadata> => {
const [serverSettings, colorScheme, t] = await Promise.all([
getRscServerSettingsAsync(),
getCurrentColorSchemeAsync(),
getI18n("metadata"),
]);
const { appName, faviconImageUrl, logoImageUrl } = serverSettings.branding;
const logo = logoImageUrl ?? "/logo/logo.png";
const favicon = faviconImageUrl ?? logo;
const description = t("description");
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
return {
title: {
default: appName,
template: `%s • ${appName}`,
},
description,
openGraph: {
title: t("dashboardTitle", { appName }),
description,
url: env.HOMARR_WEBSITE_URL,
siteName: appName,
},
icons: {
icon: favicon,
apple: logo,
},
appleWebApp: {
title: appName,
capable: true,
startupImage: { url: logo },
statusBarStyle: colorScheme === "dark" ? "black-translucent" : "default",
},
};
};
export const generateViewport = async (): Promise<Viewport> => {
const serverSettings = await getRscServerSettingsAsync();
return { themeColor: serverSettings.branding.primaryColor };
};
export default async function Layout(props: {
@@ -94,6 +105,7 @@ export default async function Layout(props: {
}
const sessionPromise = auth();
const serverSettingsPromise = getRscServerSettingsAsync();
const userPromise = sessionPromise.then((session) =>
session
? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => {
@@ -102,18 +114,17 @@ export default async function Layout(props: {
})
: null,
);
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) =>
!session
? "unauthenticated"
: api.assistant
.getAvailability()
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
.catch(() => "error"),
);
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => {
if (!session) return "unauthenticated";
return api.assistant
.getAvailability()
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
.catch(() => "error");
});
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
sessionPromise,
userPromise,
getRscServerSettingsAsync(),
serverSettingsPromise,
getCurrentColorSchemeAsync(),
assistantAvailabilityPromise,
]);
@@ -148,6 +159,7 @@ export default async function Layout(props: {
},
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
user: { enableGravatar: serverSettings.user.enableGravatar },
branding: serverSettings.branding,
}}
{...innerProps}
/>
@@ -156,7 +168,9 @@ export default async function Layout(props: {
(innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <DayJsLoader {...innerProps} />,
(innerProps) => <NextIntlClientProvider {...innerProps} />,
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
(innerProps) => (
<CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} branding={serverSettings.branding} />
),
(innerProps) => <ModalProvider {...innerProps} />,
(innerProps) => <SpotlightProvider {...innerProps} />,
(innerProps) => <AssistantGate availability={assistantAvailability} {...innerProps} />,
@@ -2,11 +2,13 @@ import { Box, Group, Image, Stack, Title } from "@mantine/core";
import { getI18n } from "@homarr/translation/server";
import { IntegrationMarquee } from "@homarr/ui";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import classes from "./hero-banner.module.css";
export const HeroBanner = async () => {
const t = await getI18n("management.page.home");
const [t, serverSettings] = await Promise.all([getI18n("management.page.home"), getRscServerSettingsAsync()]);
const { appName, logoImageUrl } = serverSettings.branding;
return (
<Box className={classes.bannerContainer} p={{ base: "lg", md: "3rem" }} bg="dark.6" pos="relative">
@@ -14,9 +16,17 @@ export const HeroBanner = async () => {
<Title fz={{ base: "h4", md: "h2" }} c="dimmed">
{t("heroBanner.title")}
</Title>
<Group gap="xs" wrap="nowrap">
<Image src="/logo/logo.png" w={{ base: 32, md: 40 }} h={{ base: 32, md: 40 }} />
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: "Homarr" })}</Title>
<Group gap="sm" wrap="nowrap">
<Image
src={logoImageUrl ?? "/logo/logo.png"}
alt={`${appName} logo`}
w="auto"
h={{ base: 48, md: 64 }}
maw={{ base: 128, md: 192 }}
fit="contain"
style={{ flexShrink: 0 }}
/>
<Title fz={{ base: "h3", md: "h1" }}>{t("heroBanner.subtitle", { app: appName })}</Title>
</Group>
</Stack>
<Box visibleFrom="md" className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
@@ -37,9 +37,8 @@ import { capitalize, objectEntries } from "@homarr/common";
import { hotkeys } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { homarrLogoPath } from "~/components/layout/logo/constants";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
import githubContributors from "@static-data/contributors.json";
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
@@ -50,7 +49,7 @@ export async function generateMetadata() {
const t = await getI18n("management");
return {
title: createMetaTitle(t("metaTitle")),
title: t("metaTitle"),
};
}
@@ -10,7 +10,7 @@ import { AssistantConfiguration } from "./_components/assistant-configuration";
export async function generateMetadata() {
const t = await getI18n("management.page.settings.section.assistant");
return {
title: `${t("title")} • Homarr`,
title: t("title"),
};
}
@@ -38,7 +38,7 @@ import { env } from "@homarr/docker/env";
import { getI18n } from "@homarr/translation/server";
import { MainHeader } from "~/components/layout/header";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { homarrLogoPath } from "~/components/layout/logo/constants";
import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
@@ -74,6 +74,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.home"),
icon: IconHomeFilled,
href: "/manage",
exact: true,
"data-onboarding-tour-id": "manage-welcome",
},
{
+1 -2
View File
@@ -7,14 +7,13 @@ import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { HeroBanner } from "./_components/hero-banner";
export async function generateMetadata(): Promise<Metadata> {
const t = await getI18n("management");
return {
title: createMetaTitle(t("metaTitle")),
title: t("metaTitle"),
};
}
@@ -0,0 +1,255 @@
"use client";
import {
Avatar,
Badge,
ColorSwatch,
Fieldset,
Group,
Paper,
SimpleGrid,
Slider,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { IconPicker } from "@homarr/forms-collection";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
interface BrandingSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
const t = useI18n("management.page.settings.section.branding");
const logoInput = form.getInputProps("branding.logoImageUrl");
const faviconInput = form.getInputProps("branding.faviconImageUrl");
const backgroundInput = form.getInputProps("branding.signInBackgroundImageUrl");
const branding = form.values.branding;
return (
<SectionCard title={t("title")}>
<Stack gap="md">
<Fieldset legend={t("groups.identity")} p="sm">
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t("identity.description")}
</Text>
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="sm" verticalSpacing="sm">
<TextInput label={t("appName.label")} {...form.getInputProps("branding.appName")} />
<IconPicker
label={t("logo.label")}
description={null}
placeholder={t("logo.placeholder")}
withAsterisk={false}
value={logoInput.value ?? ""}
onChange={(value) => form.setFieldValue("branding.logoImageUrl", value || null)}
error={logoInput.error}
/>
<IconPicker
label={t("favicon.label")}
description={null}
placeholder={t("favicon.placeholder")}
withAsterisk={false}
value={faviconInput.value ?? ""}
onChange={(value) => form.setFieldValue("branding.faviconImageUrl", value || null)}
error={faviconInput.error}
/>
</SimpleGrid>
</Stack>
</Fieldset>
<Fieldset legend={t("groups.appearance")} p="sm">
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" verticalSpacing="sm">
<Stack gap="sm">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<BoardColorInput
label={t("primaryColor.label")}
defaultColor="#fa5252"
{...form.getInputProps("branding.primaryColor")}
/>
<BoardColorInput
label={t("secondaryColor.label")}
defaultColor="#fd7e14"
{...form.getInputProps("branding.secondaryColor")}
/>
</SimpleGrid>
<Switch
label={t("lockPrimaryColor.label")}
description={t("lockPrimaryColor.description")}
{...form.getInputProps("branding.lockPrimaryColor", { type: "checkbox" })}
/>
</Stack>
<CornerStylePicker
compact
label={t("defaultRadius.label")}
value={branding.defaultRadius}
labels={
Object.fromEntries(
cornerStyleValues.map((cornerStyle) => [cornerStyle, t(`defaultRadius.options.${cornerStyle}`)]),
) as Record<(typeof cornerStyleValues)[number], string>
}
onChange={(cornerStyle) => form.setFieldValue("branding.defaultRadius", cornerStyle)}
/>
</SimpleGrid>
</Fieldset>
<Fieldset legend={t("groups.authentication")} p="sm">
<Stack gap="sm">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm" verticalSpacing="sm">
<TextInput label={t("greeting.label")} {...form.getInputProps("branding.greeting")} />
<IconPicker
label={t("signInBackground.label")}
description={null}
placeholder={t("signInBackground.placeholder")}
withAsterisk={false}
value={backgroundInput.value ?? ""}
onChange={(value) => form.setFieldValue("branding.signInBackgroundImageUrl", value || null)}
error={backgroundInput.error}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md" verticalSpacing="sm">
<Stack gap={6} justify="center">
<Group justify="space-between" gap="sm">
<Text size="sm" fw={500}>
{t("signInOverlay.label")}
</Text>
<Text size="xs" c="dimmed">
{Math.round(branding.signInBackgroundOverlay * 100)}%
</Text>
</Group>
<Slider
aria-label={t("signInOverlay.label")}
min={0}
max={0.9}
step={0.05}
label={(value) => `${Math.round(value * 100)}%`}
disabled={!branding.signInBackgroundImageUrl}
value={branding.signInBackgroundOverlay}
onChange={(value) => form.setFieldValue("branding.signInBackgroundOverlay", value)}
/>
</Stack>
<Stack gap={6}>
<Text size="sm" c="dimmed">
{t("authVisibility.description")}
</Text>
<SimpleGrid cols={3} spacing="xs">
<Switch
size="sm"
label={t("authVisibility.appName")}
{...form.getInputProps("branding.authBranding.showAppName", { type: "checkbox" })}
/>
<Switch
size="sm"
label={t("authVisibility.logo")}
{...form.getInputProps("branding.authBranding.showLogo", { type: "checkbox" })}
/>
<Switch
size="sm"
label={t("authVisibility.greeting")}
{...form.getInputProps("branding.authBranding.showGreeting", { type: "checkbox" })}
/>
</SimpleGrid>
</Stack>
</SimpleGrid>
</Stack>
</Fieldset>
<BrandingSummary form={form} />
</Stack>
</SectionCard>
);
};
const BrandingSummary = ({ form }: BrandingSettingsFormProps) => {
const t = useI18n("management.page.settings.section.branding");
const branding = form.values.branding;
const appName = branding.appName.trim() || "Homarr";
const logoImageUrl = branding.logoImageUrl || "/logo/logo.png";
const faviconImageUrl = branding.faviconImageUrl || logoImageUrl;
const backgroundStatus = branding.signInBackgroundImageUrl
? t("summary.backgroundConfigured", { overlay: Math.round(branding.signInBackgroundOverlay * 100) })
: t("summary.backgroundDefault");
const greetingStatus = branding.greeting.trim() ? t("summary.greetingConfigured") : t("summary.greetingDefault");
return (
<Paper withBorder p="sm" radius={branding.defaultRadius}>
<Stack gap="sm">
<Group justify="space-between" gap="xs" wrap="wrap">
<Text fw={650}>{t("summary.title")}</Text>
<Badge variant="light">
{t("summary.cornerStyle")}: {t(`defaultRadius.options.${branding.defaultRadius}`)}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" verticalSpacing="sm">
<Stack gap={6}>
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
{t("groups.identity")}
</Text>
<Group gap="xs" wrap="nowrap">
<Avatar.Group>
<Avatar src={logoImageUrl} alt={t("logo.label")} />
<Avatar src={faviconImageUrl} alt={t("favicon.label")} />
</Avatar.Group>
<Text size="sm" fw={600} truncate>
{appName}
</Text>
</Group>
</Stack>
<Stack gap={6}>
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
{t("summary.palette")}
</Text>
<Group gap="md">
<Group gap={6} wrap="nowrap">
<ColorSwatch color={branding.primaryColor} size={20} />
<Text size="xs">{branding.primaryColor}</Text>
</Group>
<Group gap={6} wrap="nowrap">
<ColorSwatch color={branding.secondaryColor} size={20} />
<Text size="xs">{branding.secondaryColor}</Text>
</Group>
</Group>
<Badge color={branding.lockPrimaryColor ? "primaryColor" : "gray"} variant="light" w="fit-content">
{branding.lockPrimaryColor ? t("summary.boardColorLocked") : t("summary.boardColorFlexible")}
</Badge>
</Stack>
<Stack gap={6}>
<Text size="xs" fw={700} tt="uppercase" c="dimmed">
{t("groups.authentication")}
</Text>
<Text size="sm">
{backgroundStatus} · {greetingStatus}
</Text>
<Group gap={4}>
<VisibilityBadge label={t("summary.appName")} visible={branding.authBranding.showAppName} />
<VisibilityBadge label={t("summary.logo")} visible={branding.authBranding.showLogo} />
<VisibilityBadge label={t("summary.greeting")} visible={branding.authBranding.showGreeting} />
</Group>
</Stack>
</SimpleGrid>
</Stack>
</Paper>
);
};
const VisibilityBadge = ({ label, visible }: { label: string; visible: boolean }) => {
const t = useI18n("management.page.settings.section.branding.summary");
return (
<Badge size="xs" color={visible ? "green" : "gray"} variant="light">
{label}: {visible ? t("on") : t("off")}
</Badge>
);
};
@@ -12,12 +12,14 @@ import { colorSchemes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { brandingServerSettingsSchema } from "@homarr/server-settings";
import { useI18n } from "@homarr/translation/client";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import { AnalyticsSettings } from "./analytics.settings";
import { AppearanceSettingsForm } from "./appearance-settings-form";
import { BoardSettingsForm } from "./board-settings-form";
import { BrandingSettingsForm } from "./branding-settings-form";
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
import { CultureSettingsForm } from "./culture-settings-form";
import { SearchSettingsForm } from "./search-settings-form";
@@ -37,6 +39,7 @@ const settingsFormSchema = z.object({
defaultSearchEngineId: z.string().nullable(),
defaultColorScheme: z.enum(colorSchemes),
defaultLocale: z.string(),
branding: brandingServerSettingsSchema,
});
export type FormValues = z.infer<typeof settingsFormSchema>;
@@ -55,6 +58,7 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
defaultColorScheme: initialData.appearance.defaultColorScheme,
defaultLocale: initialData.culture.defaultLocale,
branding: initialData.branding,
});
interface SettingsFormProps {
@@ -137,6 +141,11 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
value: { defaultColorScheme: values.defaultColorScheme },
},
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
{
settingsKey: "branding",
when: changed("branding"),
value: values.branding,
},
];
const promises = groups
@@ -176,6 +185,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
<UserSettingsForm form={form} />
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
<AppearanceSettingsForm form={form} />
<BrandingSettingsForm form={form} />
<CultureSettingsForm form={form} />
<AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} />
@@ -9,10 +9,9 @@ import { SettingsForm } from "./_components/settings-form";
export async function generateMetadata() {
const t = await getI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
title: t("metaTitle"),
};
}
@@ -9,7 +9,6 @@ import { auth } from "@homarr/auth/next";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
@@ -63,7 +62,7 @@ export async function generateMetadata() {
const t = await getI18n("management");
return {
title: createMetaTitle(t("metaTitle")),
title: t("metaTitle"),
};
}
@@ -5,7 +5,6 @@ import { auth } from "@homarr/auth/next";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-card";
@@ -19,7 +18,7 @@ export async function generateMetadata() {
const t = await getI18n("management.page.tool.backup");
return {
title: createMetaTitle(t("metaTitle")),
title: t("metaTitle"),
};
}
@@ -11,7 +11,6 @@ import { logsEnv } from "@homarr/core/infrastructure/logs/env";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { createMetaTitle } from "~/metadata";
import { ClientSideTerminalComponent } from "./client";
import { LogFontSizeSlider } from "./font-size-slider";
import { LogLevelSelection } from "./level-selection";
@@ -25,7 +24,7 @@ export async function generateMetadata() {
const t = await getI18n("management");
return {
title: createMetaTitle(t("metaTitle")),
title: t("metaTitle"),
};
}
@@ -6,7 +6,6 @@ import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { TasksTable } from "./_components/tasks-table";
export async function generateMetadata() {
@@ -17,7 +16,7 @@ export async function generateMetadata() {
const t = await getI18n("management");
return {
title: createMetaTitle(t("metaTitle")),
title: t("metaTitle"),
};
}
@@ -1,7 +1,9 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef } from "react";
import {
Box,
Button,
Card,
Divider,
@@ -22,12 +24,15 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import {
headerPreferencesSchema,
parseHeaderPreferences,
userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema,
userEditProfileSchema,
@@ -36,9 +41,9 @@ import {
userPingIconsEnabledSchema,
} from "@homarr/validation/user";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardSelect } from "~/components/board/board-select";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HeaderComposer } from "./header-composer";
dayjs.extend(localeData);
@@ -53,6 +58,7 @@ const userGeneralSettingsSchema = z.object({
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
enableRightClickOnWidgets: userEnableRightClickOnWidgetsSchema.shape.enableRightClickOnWidgets,
headerPreferences: headerPreferencesSchema,
});
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
@@ -65,9 +71,10 @@ const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [
interface UserGeneralSettingsFormProps {
user: RouterOutputs["user"]["getById"];
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
boardsData: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[];
searchEnginesData: { value: string; label: string }[];
showLanguageSelector?: boolean;
profileAvatar?: ReactNode;
}
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
@@ -81,6 +88,7 @@ const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled,
enableRightClickOnWidgets: user.enableRightClickOnWidgets,
headerPreferences: parseHeaderPreferences(user.headerPreferences),
});
export const UserGeneralSettingsForm = ({
@@ -88,6 +96,7 @@ export const UserGeneralSettingsForm = ({
boardsData,
searchEnginesData,
showLanguageSelector = false,
profileAvatar,
}: UserGeneralSettingsFormProps) => {
const tUser = useI18n("user");
const tCommon = useI18n("common");
@@ -101,6 +110,7 @@ export const UserGeneralSettingsForm = ({
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const changeEnableRightClickOnWidgetsMutation = clientApi.user.changeEnableRightClickOnWidgets.useMutation();
const changeHeaderPreferencesMutation = clientApi.user.changeHeaderPreferences.useMutation();
const initialValues = buildInitialValues(user);
const initialValuesRef = useRef(initialValues);
@@ -121,6 +131,7 @@ export const UserGeneralSettingsForm = ({
changeFirstDayOfWeekMutation,
changePingIconsEnabledMutation,
changeEnableRightClickOnWidgetsMutation,
changeHeaderPreferencesMutation,
];
const isPending = mutations.some((m) => m.isPending);
@@ -184,6 +195,14 @@ export const UserGeneralSettingsForm = ({
enableRightClickOnWidgets: parsed.data.enableRightClickOnWidgets,
}),
},
{
when: changed("headerPreferences"),
action: () =>
changeHeaderPreferencesMutation.mutateAsync({
id: user.id,
headerPreferences: parsed.data.headerPreferences,
}),
},
];
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
@@ -218,135 +237,149 @@ export const UserGeneralSettingsForm = ({
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tUser("name")}</Title>
{!isCredentialsUser && (
<Text c="dimmed" size="sm">
{tUserManagement("fieldsDisabledExternalProvider")}
</Text>
<Stack gap="lg">
<HeaderComposer
value={form.values.headerPreferences}
onChange={(headerPreferences) => form.setFieldValue("headerPreferences", headerPreferences)}
boards={boardsData}
homeBoardId={form.values.homeBoardId}
mobileHomeBoardId={form.values.mobileHomeBoardId}
/>
<Group gap="xl" align="flex-start" wrap="wrap">
<Box flex={1} miw={{ base: "100%", md: 540 }}>
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tUser("name")}</Title>
{!isCredentialsUser && (
<Text c="dimmed" size="sm">
{tUserManagement("fieldsDisabledExternalProvider")}
</Text>
)}
</Stack>
<Divider />
<TextInput
disabled={!isCredentialsUser}
label={tUser("field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
disabled={!isCredentialsUser}
label={tUser("field.email.label")}
{...form.getInputProps("email")}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.board.title")}</Title>
</Stack>
<Divider />
<BoardSelect
label={tGeneral("item.board.type.general")}
clearable
boards={boardsData}
w="100%"
withinPortal
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={tGeneral("item.board.type.mobile")}
clearable
boards={boardsData}
w="100%"
withinPortal
{...form.getInputProps("mobileHomeBoardId")}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.search")}</Title>
</Stack>
<Divider />
<Select
label={tUser("field.defaultSearchEngine.label")}
w="100%"
data={searchEnginesData}
comboboxProps={{ withinPortal: true }}
{...form.getInputProps("defaultSearchEngineId")}
/>
<Switch
label={tUser("field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Switch
label={tUser("field.ddgBangs.label")}
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.language")}</Title>
</Stack>
<Divider />
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
<Radio.Group
{...firstDayOfWeekInputProps}
value={firstDayOfWeekValue}
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
>
<Group mt="xs" wrap="wrap">
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
))}
</Group>
</Radio.Group>
<Divider my="xs" />
<Title order={4}>{tGeneral("item.accessibility")}</Title>
<Switch
label={tUser("field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/>
<Switch
label={tUser("field.enableRightClickOnWidgets.label")}
description={tUser("field.enableRightClickOnWidgets.description")}
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
/>
</Stack>
</Card>
</SimpleGrid>
<div style={{ position: "sticky", bottom: 20 }}>
{form.isDirty() && (
<Card withBorder>
<Group justify="space-between" wrap="wrap">
<Text fw={500}>{tCommon("unsavedChanges")}</Text>
<Group>
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
{tCommon("action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Group>
</Card>
)}
</Stack>
<Divider />
<TextInput
disabled={!isCredentialsUser}
label={tUser("field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
disabled={!isCredentialsUser}
label={tUser("field.email.label")}
{...form.getInputProps("email")}
/>
</div>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.board.title")}</Title>
</Stack>
<Divider />
<BoardSelect
label={tGeneral("item.board.type.general")}
clearable
boards={boardsData}
w="100%"
withinPortal
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={tGeneral("item.board.type.mobile")}
clearable
boards={boardsData}
w="100%"
withinPortal
{...form.getInputProps("mobileHomeBoardId")}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.search")}</Title>
</Stack>
<Divider />
<Select
label={tUser("field.defaultSearchEngine.label")}
w="100%"
data={searchEnginesData}
comboboxProps={{ withinPortal: true }}
{...form.getInputProps("defaultSearchEngineId")}
/>
<Switch
label={tUser("field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Switch
label={tUser("field.ddgBangs.label")}
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.language")}</Title>
</Stack>
<Divider />
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
<Radio.Group
{...firstDayOfWeekInputProps}
value={firstDayOfWeekValue}
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
>
<Group mt="xs" wrap="wrap">
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
))}
</Group>
</Radio.Group>
<Divider my="xs" />
<Title order={4}>{tGeneral("item.accessibility")}</Title>
<Switch
label={tUser("field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/>
<Switch
label={tUser("field.enableRightClickOnWidgets.label")}
description={tUser("field.enableRightClickOnWidgets.description")}
{...form.getInputProps("enableRightClickOnWidgets", { type: "checkbox" })}
/>
</Stack>
</Card>
</SimpleGrid>
<div style={{ position: "sticky", bottom: 20 }}>
{form.isDirty() && (
<Card withBorder>
<Group justify="space-between" wrap="wrap">
<Text fw={500}>{tCommon("unsavedChanges")}</Text>
<Group>
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
{tCommon("action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Group>
</Card>
)}
</div>
</Stack>
</form>
</form>
</Box>
{profileAvatar ? <Box w={{ base: "100%", lg: 260 }}>{profileAvatar}</Box> : null}
</Group>
</Stack>
);
};
@@ -0,0 +1,273 @@
.composer {
width: 100%;
overflow: hidden;
background: var(--mantine-color-body);
}
.heading,
.tray {
padding: var(--mantine-spacing-lg);
}
.heading {
border-bottom: 1px solid var(--mantine-color-default-border);
}
.stage {
display: grid;
place-items: center;
min-height: 21rem;
padding: var(--mantine-spacing-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
background-image: radial-gradient(var(--mantine-color-default-border) 0.75px, transparent 0.75px);
background-size: 14px 14px;
}
.device {
position: relative;
width: min(100%, 34rem);
min-height: 18rem;
overflow: hidden;
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-lg);
transition:
width 180ms ease,
min-height 180ms ease;
}
.previewHeader {
position: relative;
z-index: 1;
min-height: 5rem;
border-bottom: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
direction: ltr;
}
.zoneTable {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
min-height: 5rem;
}
.zoneColumn {
min-width: 0;
border-inline-end: 1px solid var(--mantine-color-default-border);
transition: background-color 120ms ease;
}
.zoneColumn:last-child {
border-inline-end: 0;
}
.zoneColumn[data-drop-target="true"] {
background: var(--mantine-primary-color-light);
}
.zoneColumn[data-drop-target="true"] .zoneItems::after {
content: "";
width: 2.25rem;
height: 2.25rem;
border: 1px dashed var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
background: color-mix(in srgb, var(--mantine-primary-color-light) 65%, transparent);
}
.zoneColumnHeader {
display: grid;
place-items: center;
height: 1.75rem;
margin: 0;
color: var(--mantine-color-dimmed);
border-bottom: 1px solid var(--mantine-color-default-border);
font-size: 0.55rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0.06em;
text-transform: uppercase;
user-select: none;
}
.zoneItems {
display: flex;
align-content: flex-start;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.25rem;
min-height: 3.25rem;
padding: 0.5rem;
}
.sortableItem {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
}
.sortableItem[data-drop-target="true"]::before {
content: "";
position: absolute;
inset-block: 0.2rem;
inset-inline-start: -0.2rem;
z-index: 1;
width: 2px;
border-radius: var(--mantine-radius-xl);
background: var(--mantine-primary-color-filled);
box-shadow: 0 0 0 2px var(--mantine-primary-color-light);
}
.sortableItem[data-dragging="true"] {
opacity: 0.2;
}
.itemButton,
.dragOverlay {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
min-width: 2.25rem;
padding: 0;
overflow: hidden;
color: var(--mantine-color-text);
border: 1px solid transparent;
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mantine-color-default-border) 76%, transparent);
}
.itemButton {
cursor: grab;
transition:
border-color 120ms ease,
background-color 120ms ease,
box-shadow 120ms ease;
}
.itemButton[data-wide="true"],
.dragOverlay[data-wide="true"] {
width: 4.75rem;
}
.itemButton:hover,
.itemButton:focus-visible,
.sortableItem[data-drop-target="true"] .itemButton,
.sortableItem[data-selected="true"] .itemButton {
border-color: var(--mantine-primary-color-filled);
background: var(--mantine-primary-color-light-hover);
box-shadow: 0 0 0 1px var(--mantine-primary-color-filled);
}
.itemButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.dragOverlay {
pointer-events: none;
border-color: var(--mantine-primary-color-filled);
box-shadow: var(--mantine-shadow-xl);
}
.searchPreview {
display: grid;
place-items: center;
width: 100%;
min-width: 0;
color: var(--mantine-color-text);
}
.logoPreview {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
width: 100%;
min-width: 0;
}
.logoPreview[data-with-title="true"] {
justify-content: flex-start;
padding-inline: 0.375rem;
}
.logoPreview span {
min-width: 0;
overflow: hidden;
font-size: var(--mantine-font-size-xs);
font-weight: 600;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.searchPreview[data-input="true"] {
grid-template-columns: auto minmax(0, 1fr);
gap: 0.25rem;
padding-inline: 0.375rem;
font-size: var(--mantine-font-size-xs);
text-align: start;
}
.searchPreview span {
width: 100%;
min-width: 0;
overflow: hidden;
color: var(--mantine-color-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
}
.hiddenPreview {
position: absolute;
inset: 0 0 auto;
z-index: 1;
display: flex;
justify-content: flex-end;
padding: var(--mantine-spacing-md);
}
.floatingAccount {
box-shadow: var(--mantine-shadow-md);
}
.dashboardPreview {
height: 13rem;
overflow: hidden;
opacity: 0.86;
}
.dashboardLayout {
height: 100%;
aspect-ratio: auto;
}
.tray {
border-top: 1px solid var(--mantine-color-default-border);
}
.configuration {
background: color-mix(in srgb, var(--mantine-primary-color-light) 42%, var(--mantine-color-body));
}
@media (max-width: $mantine-breakpoint-sm) {
.heading,
.tray,
.stage {
padding: var(--mantine-spacing-md);
}
.stage {
min-height: 19rem;
}
}
@media (prefers-reduced-motion: reduce) {
.device,
.zoneColumn,
.itemButton {
transition: none;
}
}
@@ -0,0 +1,653 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { PointerActivationConstraints } from "@dnd-kit/dom";
import { SortableKeyboardPlugin } from "@dnd-kit/dom/sortable";
import type { DragEndEvent } from "@dnd-kit/react";
import { DragDropProvider, DragOverlay, KeyboardSensor, PointerSensor, useDroppable } from "@dnd-kit/react";
import { useSortable } from "@dnd-kit/react/sortable";
import {
ActionIcon,
Avatar,
Box,
Button,
Card,
Group,
Menu,
Paper,
SegmentedControl,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
IconBrandDocker,
IconEye,
IconEyeOff,
IconHome,
IconLayoutDashboard,
IconPencil,
IconPlus,
IconReplace,
IconRobot,
IconSearch,
IconSettings,
IconSunMoon,
IconUserCircle,
IconX,
} from "@tabler/icons-react";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type {
HeaderBuiltinItemId,
HeaderItem,
HeaderPreferences,
HeaderZoneId,
HeaderZones,
} from "@homarr/validation/user";
import {
createBoardHeaderItem,
createBuiltinHeaderItem,
getDefaultHeaderZone,
getHeaderItemKey,
getHeaderItems,
getHeaderItemZone,
headerBuiltinItemIds,
isRequiredHeaderItem,
} from "@homarr/validation/user";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { HomarrLogo } from "~/components/layout/logo/homarr-logo";
import classes from "./header-composer.module.css";
interface HeaderComposerProps {
value: HeaderPreferences;
onChange: (value: HeaderPreferences) => void;
boards: { id: string; name: string; logoImageUrl: string | null; preview: BoardPreviewData | null }[];
homeBoardId: string | null;
mobileHomeBoardId: string | null;
}
interface HeaderItemDragData {
kind: "item";
itemKey: string;
zone: HeaderZoneId;
index: number;
}
interface HeaderZoneDropData {
kind: "zone";
zone: HeaderZoneId;
}
type HeaderDropData = HeaderItemDragData | HeaderZoneDropData;
const headerSortableSensors = [
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 6 })],
}),
KeyboardSensor,
];
const headerSortableGroup = "header-composer";
const headerSortablePlugins = [SortableKeyboardPlugin];
const headerGuideZones = ["left", "center", "right"] as const;
export const HeaderComposer = ({ value, onChange, boards, homeBoardId }: HeaderComposerProps) => {
const [draggedItemKey, setDraggedItemKey] = useState<string | null>(null);
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const zoneTableRef = useRef<HTMLDivElement>(null);
const { branding } = useSettings();
const t = useI18n("management.page.user.setting.general.header");
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
const activeItems = getHeaderItems(value.zones);
useEffect(() => {
const zoneTable = zoneTableRef.current;
if (!zoneTable) return;
const clearSelectionFromEmptyArea = (event: PointerEvent) => {
if (!(event.target instanceof Element)) return;
if (event.target.closest("[data-header-composer-item]")) return;
setSelectedItemKey(null);
};
zoneTable.addEventListener("pointerdown", clearSelectionFromEmptyArea);
return () => zoneTable.removeEventListener("pointerdown", clearSelectionFromEmptyArea);
}, []);
const activeItemKeySet = new Set(activeItems.map(getHeaderItemKey));
const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => {
const item = createBuiltinHeaderItem(itemId);
return !isRequiredHeaderItem(item) && !activeItemKeySet.has(getHeaderItemKey(item));
});
const availableBoards = boards.filter(
(board) => !activeItemKeySet.has(getHeaderItemKey(createBoardHeaderItem(board.id))),
);
const draggedItem = findHeaderItem(activeItems, draggedItemKey);
const selectedItem = findHeaderItem(activeItems, selectedItemKey);
const previewBoard = boardsById.get(homeBoardId ?? "") ?? boards.at(0);
const toggleSelectedItem = (itemKey: string) => {
setSelectedItemKey((currentItemKey) => {
if (currentItemKey === itemKey) return null;
return itemKey;
});
};
const itemLabel = (item: HeaderItem) => {
if (item.type === "board") return boardsById.get(item.boardId)?.name ?? t("items.boardUnavailable");
return t(`items.${item.id}` as never);
};
const updateZones = (zones: HeaderZones) => onChange({ ...value, zones });
const removeItem = (item: HeaderItem) => {
if (isRequiredHeaderItem(item)) return;
const zone = getHeaderItemZone(value.zones, item);
if (!zone) return;
const itemKey = getHeaderItemKey(item);
updateZones({
...value.zones,
[zone]: value.zones[zone].filter((candidate) => getHeaderItemKey(candidate) !== itemKey),
});
setSelectedItemKey(null);
};
const addItem = (item: HeaderItem) => {
const itemKey = getHeaderItemKey(item);
if (activeItemKeySet.has(itemKey)) return;
const zone = getDefaultHeaderZone(item);
updateZones({ ...value.zones, [zone]: [...value.zones[zone], item] });
setSelectedItemKey(itemKey);
};
const handleDragEnd = ({ canceled, operation }: DragEndEvent) => {
setDraggedItemKey(null);
const source = operation.source;
const target = operation.target;
if (canceled || !source || !target) return;
const sourceData = source.data as HeaderItemDragData;
const targetData = target.data as HeaderDropData;
if (sourceData.kind !== "item") return;
let targetIndex = value.zones[targetData.zone].length;
if (targetData.kind === "item") {
if (targetData.itemKey === sourceData.itemKey) return;
targetIndex = targetData.index;
}
const nextZones = moveHeaderItem(value.zones, sourceData.itemKey, targetData.zone, targetIndex);
if (nextZones === value.zones) return;
updateZones(nextZones);
};
return (
<Card className={classes.composer} padding={0} radius="lg" withBorder>
<Stack gap={0}>
<Group className={classes.heading} justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={4}>
<Group gap="xs">
<ThemeIcon variant="light" radius="md" size="lg">
<IconLayoutDashboard size={18} />
</ThemeIcon>
<Text fw={700} size="lg">
{t("title")}
</Text>
</Group>
<Text c="dimmed" size="sm">
{t("description")}
</Text>
</Stack>
<Group gap="xs">
<Tooltip label={value.visible ? t("action.hide") : t("action.show")}>
<ActionIcon
size="lg"
variant={value.visible ? "default" : "filled"}
aria-label={value.visible ? t("action.hide") : t("action.show")}
onClick={() => onChange({ ...value, visible: !value.visible })}
>
{value.visible ? <IconEye size={18} /> : <IconEyeOff size={18} />}
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Box className={classes.stage} data-visible={value.visible}>
<Paper className={classes.device} radius="lg" withBorder>
{value.visible ? (
<DragDropProvider
onDragStart={({ operation }) => setDraggedItemKey(operation.source?.id.toString() ?? null)}
onDragEnd={handleDragEnd}
>
<div className={classes.previewHeader}>
<div ref={zoneTableRef} className={classes.zoneTable}>
{headerGuideZones.map((zone) => (
<HeaderZoneColumn key={zone} zone={zone} label={t(`zones.${zone}` as never)}>
{value.zones[zone].map((item, index) => (
<SortableHeaderItem
key={getHeaderItemKey(item)}
item={item}
zone={zone}
index={index}
label={itemLabel(item)}
searchDisplay={value.searchDisplay}
logoDisplay={value.logoDisplay}
logoTitle={branding.appName}
boardLogo={
item.type === "board" ? (boardsById.get(item.boardId)?.logoImageUrl ?? null) : null
}
configureLabel={t("action.configure", { item: itemLabel(item) })}
selected={selectedItemKey === getHeaderItemKey(item)}
onSelect={toggleSelectedItem}
/>
))}
</HeaderZoneColumn>
))}
</div>
</div>
<DragOverlay dropAnimation={null}>
{draggedItem ? (
<HeaderItemPreview
item={draggedItem}
label={itemLabel(draggedItem)}
compact={false}
searchDisplay={value.searchDisplay}
logoDisplay={value.logoDisplay}
logoTitle={branding.appName}
boardLogo={
draggedItem.type === "board" ? boardsById.get(draggedItem.boardId)?.logoImageUrl : null
}
overlay
/>
) : null}
</DragOverlay>
</DragDropProvider>
) : (
<div className={classes.hiddenPreview}>
<ThemeIcon className={classes.floatingAccount} radius="xl" size="xl" aria-label={t("items.user")}>
<IconUserCircle size={22} />
</ThemeIcon>
</div>
)}
<div className={classes.dashboardPreview}>
<BoardLayoutThumbnail
preview={previewBoard?.preview ?? null}
label={previewBoard ? t("preview.board", { name: previewBoard.name }) : t("preview.empty")}
className={classes.dashboardLayout}
/>
</div>
</Paper>
</Box>
<Stack className={classes.tray} gap="sm">
{selectedItem ? (
<Paper className={classes.configuration} p="sm" radius="md" withBorder>
<Group justify="space-between" gap="sm" wrap="wrap">
<Stack gap={0}>
<Text fw={650} size="sm">
{itemLabel(selectedItem)}
</Text>
<Text c="dimmed" size="xs">
{t("configuration.description")}
</Text>
</Stack>
<Group gap="xs">
{selectedItem.type === "builtin" && selectedItem.id === "search" ? (
<SegmentedControl
size="xs"
value={value.searchDisplay}
onChange={(searchDisplay) =>
onChange({ ...value, searchDisplay: searchDisplay as HeaderPreferences["searchDisplay"] })
}
data={[
{ value: "input", label: t("configuration.searchDisplay.input") },
{ value: "icon", label: t("configuration.searchDisplay.icon") },
]}
/>
) : null}
{selectedItem.type === "builtin" && selectedItem.id === "logo" ? (
<SegmentedControl
size="xs"
value={value.logoDisplay}
onChange={(logoDisplay) =>
onChange({ ...value, logoDisplay: logoDisplay as HeaderPreferences["logoDisplay"] })
}
data={[
{ value: "logo", label: t("configuration.logoDisplay.logo") },
{ value: "logoAndText", label: t("configuration.logoDisplay.logoAndText") },
]}
/>
) : null}
{!isRequiredHeaderItem(selectedItem) ? (
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconX size={14} />}
onClick={() => removeItem(selectedItem)}
>
{t("action.remove", { item: itemLabel(selectedItem) })}
</Button>
) : (
<Text c="dimmed" size="xs">
{t("available.requiredAlways")}
</Text>
)}
</Group>
</Group>
</Paper>
) : null}
<Group justify="space-between" gap="xs">
<Stack gap={0}>
<Text fw={650} size="sm">
{t("available.title")}
</Text>
<Text c="dimmed" size="xs">
{t("available.description")}
</Text>
</Stack>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<Button
size="xs"
variant="default"
leftSection={<IconPlus size={14} />}
disabled={availableBoards.length === 0}
>
{t("available.boardShortcut")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("available.chooseBoard")}</Menu.Label>
{availableBoards.map((board) => (
<Menu.Item
key={board.id}
leftSection={
<Avatar src={board.logoImageUrl} size="xs" radius="sm">
<IconLayoutDashboard size={14} />
</Avatar>
}
onClick={() => addItem(createBoardHeaderItem(board.id))}
>
{board.name}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</Group>
<Group gap="xs">
{inactiveBuiltinIds.length === 0 ? (
<Text c="dimmed" size="sm">
{t("available.allAdded")}
</Text>
) : (
inactiveBuiltinIds.map((itemId) => {
const item = createBuiltinHeaderItem(itemId);
const Icon = getBuiltinItemIcon(itemId);
return (
<Tooltip key={itemId} label={t("available.add", { item: itemLabel(item) })}>
<ActionIcon
size="lg"
variant="default"
aria-label={t("available.add", { item: itemLabel(item) })}
onClick={() => addItem(item)}
>
<Icon size={19} stroke={1.7} />
</ActionIcon>
</Tooltip>
);
})
)}
</Group>
</Stack>
</Stack>
</Card>
);
};
interface HeaderZoneColumnProps {
zone: HeaderZoneId;
label: string;
children: ReactNode;
}
const HeaderZoneColumn = ({ zone, label, children }: HeaderZoneColumnProps) => {
const { isDropTarget, ref } = useDroppable({
id: `header-zone:${zone}`,
data: { kind: "zone", zone } satisfies HeaderZoneDropData,
});
return (
<section ref={ref} className={classes.zoneColumn} data-drop-target={isDropTarget || undefined}>
<Text className={classes.zoneColumnHeader} component="h4">
{label}
</Text>
<div className={classes.zoneItems}>{children}</div>
</section>
);
};
interface SortableHeaderItemProps {
item: HeaderItem;
zone: HeaderZoneId;
index: number;
label: string;
searchDisplay: HeaderPreferences["searchDisplay"];
logoDisplay: HeaderPreferences["logoDisplay"];
logoTitle: string;
boardLogo: string | null;
configureLabel: string;
selected: boolean;
onSelect: (itemKey: string) => void;
}
const SortableHeaderItem = ({
item,
zone,
index,
label,
searchDisplay,
logoDisplay,
logoTitle,
boardLogo,
configureLabel,
selected,
onSelect,
}: SortableHeaderItemProps) => {
const itemKey = getHeaderItemKey(item);
const { handleRef, isDragging, isDropTarget, ref } = useSortable({
id: itemKey,
index,
group: `${headerSortableGroup}:${zone}`,
data: { kind: "item", itemKey, zone, index } satisfies HeaderItemDragData,
sensors: headerSortableSensors,
plugins: headerSortablePlugins,
});
return (
<div
ref={ref}
className={classes.sortableItem}
data-dragging={isDragging || undefined}
data-drop-target={isDropTarget || undefined}
data-entry-id={itemKey}
data-selected={selected || undefined}
>
<button
ref={handleRef}
type="button"
className={classes.itemButton}
data-header-composer-item
data-kind={item.type === "builtin" ? item.id : "board"}
data-wide={
(item.type === "builtin" &&
((item.id === "search" && searchDisplay === "input") ||
(item.id === "logo" && logoDisplay === "logoAndText"))) ||
undefined
}
aria-label={configureLabel}
onClick={(event) => {
event.stopPropagation();
onSelect(itemKey);
}}
>
<HeaderItemPreview
item={item}
label={label}
compact={false}
searchDisplay={searchDisplay}
logoDisplay={logoDisplay}
logoTitle={logoTitle}
boardLogo={boardLogo}
/>
</button>
</div>
);
};
const HeaderItemPreview = ({
item,
label,
compact,
searchDisplay,
logoDisplay,
logoTitle,
boardLogo,
overlay = false,
}: {
item: HeaderItem;
label: string;
compact: boolean;
searchDisplay: HeaderPreferences["searchDisplay"];
logoDisplay: HeaderPreferences["logoDisplay"];
logoTitle: string;
boardLogo: string | null | undefined;
overlay?: boolean;
}) => {
const content = (
<PreviewItem
item={item}
label={label}
compact={compact}
searchDisplay={searchDisplay}
logoDisplay={logoDisplay}
logoTitle={logoTitle}
boardLogo={boardLogo}
/>
);
if (!overlay) return content;
return (
<div
className={classes.dragOverlay}
data-kind={item.type === "builtin" ? item.id : "board"}
data-wide={
(item.type === "builtin" &&
!compact &&
((item.id === "search" && searchDisplay === "input") ||
(item.id === "logo" && logoDisplay === "logoAndText"))) ||
undefined
}
>
{content}
</div>
);
};
const PreviewItem = ({
item,
label,
compact,
searchDisplay,
logoDisplay,
logoTitle,
boardLogo,
}: {
item: HeaderItem;
label: string;
compact: boolean;
searchDisplay: HeaderPreferences["searchDisplay"];
logoDisplay: HeaderPreferences["logoDisplay"];
logoTitle: string;
boardLogo: string | null | undefined;
}) => {
if (item.type === "board") {
return (
<Avatar src={boardLogo} size={22} radius="sm">
<IconLayoutDashboard size={17} />
</Avatar>
);
}
if (item.id === "logo") {
return (
<div className={classes.logoPreview} data-with-title={logoDisplay === "logoAndText" || undefined}>
<HomarrLogo size={26} />
{logoDisplay === "logoAndText" ? <span>{logoTitle}</span> : null}
</div>
);
}
if (item.id === "search") {
const showInput = searchDisplay === "input" && !compact;
return (
<div className={classes.searchPreview} data-input={showInput || undefined}>
<IconSearch size={18} />
{showInput ? <span>{label}…</span> : null}
</div>
);
}
const Icon = getBuiltinItemIcon(item.id);
return <Icon size={20} stroke={1.7} />;
};
const moveHeaderItem = (
zones: HeaderZones,
itemKey: string,
targetZone: HeaderZoneId,
requestedTargetIndex: number,
): HeaderZones => {
let sourceItem: HeaderItem | undefined;
for (const zone of headerGuideZones) {
sourceItem = zones[zone].find((item) => getHeaderItemKey(item) === itemKey);
if (sourceItem) break;
}
if (!sourceItem) return zones;
const nextZones: HeaderZones = {
left: zones.left.filter((item) => getHeaderItemKey(item) !== itemKey),
center: zones.center.filter((item) => getHeaderItemKey(item) !== itemKey),
right: zones.right.filter((item) => getHeaderItemKey(item) !== itemKey),
};
const targetItems = nextZones[targetZone];
const targetIndex = Math.max(0, Math.min(requestedTargetIndex, targetItems.length));
nextZones[targetZone] = [...targetItems.slice(0, targetIndex), sourceItem, ...targetItems.slice(targetIndex)];
return nextZones;
};
const findHeaderItem = (items: HeaderItem[], id: string | null): HeaderItem | undefined => {
if (id === null) return undefined;
return items.find((item) => getHeaderItemKey(item) === id);
};
const getBuiltinItemIcon = (itemId: HeaderBuiltinItemId) => {
if (itemId === "search") return IconSearch;
if (itemId === "home") return IconHome;
if (itemId === "boardSwitcher") return IconReplace;
if (itemId === "assistant") return IconRobot;
if (itemId === "docker") return IconBrandDocker;
if (itemId === "boardEdit") return IconPencil;
if (itemId === "boardSettings") return IconSettings;
if (itemId === "settings") return IconSettings;
if (itemId === "themeToggle") return IconSunMoon;
if (itemId === "user") return IconUserCircle;
return IconLayoutDashboard;
};
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation";
import { Alert, Box, Group, Stack, Title } from "@mantine/core";
import { Alert, Stack, Title } from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
@@ -8,7 +8,6 @@ import { getI18n } from "@homarr/translation/server";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
@@ -38,7 +37,7 @@ export async function generateMetadata(props: Props) {
return {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
title: createMetaTitle(t("metaTitle", { username: user.name! })),
title: t("metaTitle", { username: user.name! }),
};
}
@@ -58,7 +57,7 @@ export default async function EditUserPage(props: Props) {
notFound();
}
const boards = await api.board.getAllBoards();
const boards = await api.board.getManageOverview({ fullPreview: false, userId: user.id });
const searchEngines = await api.searchEngine.getSelectable();
const isSelf = session?.user.id === user.id;
const isCredentialsUser = user.provider === "credentials";
@@ -71,23 +70,18 @@ export default async function EditUserPage(props: Props) {
</Alert>
)}
<Title>{tGeneral("title")}</Title>
<Group gap="xl" align="flex-start" wrap="wrap">
<Box flex={1} miw={{ base: "100%", md: 540 }}>
<UserGeneralSettingsForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
}))}
searchEnginesData={searchEngines}
showLanguageSelector={isSelf}
/>
</Box>
<Box w={{ base: "100%", lg: 260 }}>
<UserProfileAvatarForm user={user} />
</Box>
</Group>
<UserGeneralSettingsForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
preview: board.preview,
}))}
searchEnginesData={searchEngines}
showLanguageSelector={isSelf}
profileAvatar={<UserProfileAvatarForm user={user} />}
/>
{session?.user.id === user.id && (
<Stack mb="lg">
@@ -8,7 +8,6 @@ import { everyoneGroup } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
export async function generateMetadata() {
@@ -22,7 +21,7 @@ export async function generateMetadata() {
const t = await getI18n("management.page.user.create");
return {
title: createMetaTitle(t("metaTitle")),
title: t("metaTitle"),
};
}
@@ -9,7 +9,6 @@ import { Link } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { createMetaTitle } from "~/metadata";
import { UserListComponent } from "./_components/user-list";
export async function generateMetadata() {
@@ -20,7 +19,7 @@ export async function generateMetadata() {
const t = await getI18n("management.page.user.list");
return {
title: createMetaTitle(t("metaTitle")),
title: t("metaTitle"),
};
}
@@ -4,6 +4,7 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
import type { ToolSet, UIMessage } from "ai";
import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
import { cookies } from "next/headers";
import { z } from "zod/v4";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
@@ -24,6 +25,10 @@ import {
assistantProviderRequiresApiKey,
assistantReasoningModes,
} from "@homarr/definitions";
import { localeCookieKey } from "@homarr/definitions/cookie";
import type { SupportedLanguage } from "@homarr/translation";
import { fallbackLocale, isLocaleSupported } from "@homarr/translation";
import { getI18n } from "@homarr/translation/server";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts";
@@ -370,7 +375,7 @@ export async function POST(request: Request) {
const session = await auth();
if (!session) {
return Response.json({ error: "Sign in to use Homarr Assistant." }, { status: 401 });
return Response.json({ error: "Sign in to use Assistant." }, { status: 401 });
}
const requestBody = await request.text();
@@ -414,7 +419,11 @@ export async function POST(request: Request) {
const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
if (!configuration?.enabled || !configuration.modelId || (requiresApiKey && !configuration.encryptedApiKey)) {
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
const requestedLocale = (await cookies()).get(localeCookieKey)?.value;
let locale: SupportedLanguage = fallbackLocale;
if (requestedLocale && isLocaleSupported(requestedLocale)) locale = requestedLocale;
const t = await getI18n({ locale, namespace: "assistant.unavailable" });
return Response.json({ error: t("notConfigured") }, { status: 503 });
}
let homarrProviderToken: string | null | undefined;
+50 -30
View File
@@ -1,39 +1,59 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
export const dynamic = "force-dynamic";
export default async function manifest(): Promise<MetadataRoute.Manifest> {
const serverSettings = await getRscServerSettingsAsync();
const branding = serverSettings.branding;
const appIcon = branding.logoImageUrl ?? branding.faviconImageUrl;
const icons: NonNullable<MetadataRoute.Manifest["icons"]> = [
{
src: "/images/pwa/192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/images/pwa/512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
];
if (appIcon) {
icons.unshift({
src: appIcon,
sizes: "any",
purpose: "any",
});
} else {
icons.unshift(
{
src: "/images/pwa/192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/images/pwa/512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
);
}
return {
name: "Homarr",
short_name: "Homarr",
name: branding.appName,
short_name: branding.appName,
description: "Your dashboard for managing your server.",
start_url: "/",
display: "standalone",
background_color: "#fff",
theme_color: "#fff",
icons: [
{
src: "/images/pwa/192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/images/pwa/192.maskable.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/images/pwa/512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/images/pwa/512.maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
theme_color: branding.primaryColor,
icons,
};
}
@@ -220,7 +220,7 @@ const renderMessage = (message: AssistantConversationExportMessage, index: numbe
export const buildAssistantMessageMarkdown = (message: AssistantConversationExportMessage, exportedAt = new Date()) => {
const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`;
return `# ${title}\n\n> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
return `# ${title}\n\n> Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
};
export const buildAssistantConversationMarkdown = ({
@@ -233,7 +233,7 @@ export const buildAssistantConversationMarkdown = ({
const header = [
`# ${title}`,
"",
"> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
"> Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
"",
`- Conversation ID: \`${thread.id}\``,
`- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`,
@@ -25,7 +25,6 @@ const EnabledAssistantRoot = dynamic(() =>
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
useRegisterAssistantSpotlightPlaceholder(description);
const value = useMemo(
() => ({
enabled: false,
@@ -5,10 +5,10 @@
overflow: hidden;
color: var(--mantine-color-dimmed);
background:
radial-gradient(
circle at 15% 10%,
linear-gradient(
to top,
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
transparent 35%
transparent 55%
),
var(--mantine-color-body);
}
@@ -12,15 +12,22 @@ interface BoardLayoutThumbnailProps {
preview: BoardPreviewData | null;
label: string;
fitFullLayout?: boolean;
className?: string;
}
const maxThumbnailRows = 12;
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
export const BoardLayoutThumbnail = ({
preview,
label,
fitFullLayout = false,
className,
}: BoardLayoutThumbnailProps) => {
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
if (!preview || !layout) {
return (
<Center className={classes.canvas} role="img" aria-label={label}>
<Center className={canvasClassName} role="img" aria-label={label}>
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
</Center>
);
@@ -38,7 +45,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
});
if (lanes.length === 0) {
return (
<Center className={classes.canvas} role="img" aria-label={label}>
<Center className={canvasClassName} role="img" aria-label={label}>
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
</Center>
);
@@ -55,7 +62,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
};
return (
<Box className={classes.canvas} role="img" aria-label={label}>
<Box className={canvasClassName} role="img" aria-label={label}>
<div
className={classes.lanes}
style={{ gridTemplateColumns: lanes.map(({ columnCount }) => `${columnCount}fr`).join(" ") }}
@@ -36,8 +36,14 @@ import classes from "./board-switcher.module.css";
export const boardSwitcherHotkey = "shift+c";
export interface BoardSwitcherControls {
open: () => void;
preload: () => void;
hotkey: string;
}
interface BoardSwitcherProps {
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode;
children: (controls: BoardSwitcherControls) => ReactNode;
}
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
@@ -222,7 +222,9 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
return <>{children}</>;
}
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
@@ -24,6 +24,7 @@ export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }
name: innerProps.previousName,
});
void utils.board.getHomeBoard.invalidate();
void utils.board.getAllBoards.invalidate();
},
});
const form = useZodForm(boardRenameSchema.omit({ id: true }), {
@@ -9,8 +9,8 @@ const editorBoundaryPath = resolve(gridDirectory, "board-grid-editor-boundary.ts
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
describe("board grid import boundary", () => {
test("keeps every current dnd-kit runtime import inside the edit-only module", () => {
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
test("keeps board grid dnd-kit runtime imports inside the edit-only module", () => {
const runtimeImports = getSourceFiles(gridDirectory)
.flatMap(getRuntimeImports)
.filter(({ specifier }) => isCurrentDndKitImport(specifier))
.map(({ filePath, kind, specifier }) => ({
@@ -1 +1,2 @@
export const appShellHeaderHeight = 60;
export const appShellLogoHeight = appShellHeaderHeight - 8;
+33 -37
View File
@@ -1,49 +1,45 @@
import type { ReactNode } from "react";
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { Link } from "@homarr/ui";
import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
import { LazySpotlight } from "./header/lazy-spotlight";
import { CurrentUserAvatar } from "~/components/user-avatar";
import { appShellLogoHeight } from "./constants";
import { ConfigurableHeader } from "./header/configurable-header";
import { HomarrLogo, HomarrLogoWithTitle } from "./logo/homarr-logo";
interface Props {
logo?: ReactNode;
logoWithTitle?: ReactNode;
actions?: ReactNode;
boardEditAction?: ReactNode;
boardSettingsAction?: ReactNode;
hasNavigation?: boolean;
}
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
export const MainHeader = async ({
logo,
logoWithTitle,
actions,
boardEditAction,
boardSettingsAction,
hasNavigation = true,
}: Props) => {
const session = await auth();
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
return (
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
style={{ overflowX: "hidden" }}
data-advanced-focus-background
data-app-shell-header
>
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <ClientBurger />}
<UnstyledButton component={Link} href="/">
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
</Group>
<TourTarget id="board-search">
<DesktopSearchInput />
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{actions}
<MobileSearchButton />
<TourTarget id="board-user-menu">
<UserButton />
</TourTarget>
</Group>
</Group>
<LazySpotlight />
</AppShellHeader>
<ConfigurableHeader
logo={logo ?? <HomarrLogo size={appShellLogoHeight} />}
logoWithTitle={logoWithTitle ?? <HomarrLogoWithTitle size="md" />}
actions={actions}
boardEditAction={boardEditAction}
boardSettingsAction={boardSettingsAction}
hasNavigation={hasNavigation}
avatar={<CurrentUserAvatar size="md" />}
userId={session?.user.id ?? null}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
/>
);
};
@@ -0,0 +1,142 @@
.header {
overflow: hidden;
}
.content {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center;
gap: var(--mantine-spacing-sm);
width: 100%;
height: 100%;
padding-inline: var(--mantine-spacing-md);
}
.zone {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.zone::-webkit-scrollbar {
display: none;
}
.zone[data-zone="left"] {
justify-content: flex-start;
}
.zone[data-zone="center"] {
justify-content: center;
}
.zone[data-zone="right"] {
justify-content: flex-end;
}
.search {
display: flex;
min-width: 0;
max-width: 100%;
}
.search[data-display="icon"] {
width: 2.25rem;
flex: 0 0 2.25rem;
}
.search > * {
max-width: 100%;
}
.zone [data-tour-target="board-search"] {
min-width: 0;
max-width: 100%;
}
.contextActions {
flex: 0 0 auto;
}
.floatingControls {
position: fixed;
z-index: var(--homarr-z-index-board-header);
top: max(rem(10px), env(safe-area-inset-top));
right: max(rem(10px), env(safe-area-inset-right));
padding: rem(8px);
border: rem(1px) solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--mantine-color-body) 58%, transparent);
box-shadow: 0 rem(12px) rem(36px) rgb(0 0 0 / 20%);
backdrop-filter: blur(rem(20px)) saturate(140%);
opacity: 0;
pointer-events: none;
transform: translateY(rem(-8px)) scale(0.98);
transition:
opacity 160ms ease,
transform 180ms ease;
}
.floatingControls[data-visible="true"],
.floatingControls:focus-within {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
}
.floatingControls button {
display: flex;
align-items: center;
justify-content: center;
}
.floatingControlsCorner {
position: fixed;
z-index: calc(var(--homarr-z-index-board-header) - 1);
top: 0;
right: 0;
width: rem(112px);
height: rem(112px);
pointer-events: none;
}
.floatingControlsCorner[data-active="true"] {
pointer-events: auto;
}
@media (max-width: $mantine-breakpoint-sm) {
.content {
gap: var(--mantine-spacing-xs);
padding-inline: var(--mantine-spacing-xs);
}
.zone {
gap: var(--mantine-spacing-xs);
}
.search {
width: auto;
flex: 0 0 auto;
}
}
@media (hover: none), (pointer: coarse) {
.floatingControls {
opacity: 1;
pointer-events: auto;
transform: none;
}
.floatingControlsCorner {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.floatingControls {
transition: none;
}
}
@@ -0,0 +1,418 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
AppShellHeader,
Avatar,
Group,
Indicator,
Loader,
Tooltip,
useMantineColorScheme,
} from "@mantine/core";
import {
IconBrandDocker,
IconHome,
IconLayoutDashboard,
IconReplace,
IconRobot,
IconSettings,
IconSunMoon,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { HeaderItem } from "@homarr/validation/user";
import { getHeaderItemKey, getHeaderItems } from "@homarr/validation/user";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
import { BoardSwitcher } from "~/components/board/board-switcher";
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
import { ClientBurger } from "./burger";
import { HeaderButton } from "./button";
import { DockerQuickAccessModal } from "./docker-quick-access-modal";
import { HeaderLogo } from "./header-logo";
import { LazySpotlight } from "./lazy-spotlight";
import { DesktopSearchInput, MobileSearchButton } from "./search";
import { TourTarget } from "./tour-target";
import { UserButtonClient } from "./user-client";
import classes from "./configurable-header.module.css";
interface ConfigurableHeaderProps {
logo: ReactNode;
logoWithTitle: ReactNode;
actions?: ReactNode;
boardEditAction?: ReactNode;
boardSettingsAction?: ReactNode;
hasNavigation: boolean;
avatar: ReactNode;
userId: string | null;
isAdmin: boolean;
isDockerEnabled: boolean;
}
type HeaderBoard = RouterOutputs["board"]["getAllBoards"][number];
const floatingControlsIntroDurationMs = 5_000;
const floatingControlsDismissDelayMs = 900;
export const ConfigurableHeader = ({
logo,
logoWithTitle,
actions,
boardEditAction,
boardSettingsAction,
hasNavigation,
avatar,
userId,
isAdmin,
isDockerEnabled,
}: ConfigurableHeaderProps) => {
const { headerPreferences } = useSettings();
const assistant = useOptionalHomarrAssistant();
const { toggleColorScheme } = useMantineColorScheme();
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const t = useI18n("management.page.user.setting.general.header");
const allHeaderItems = getHeaderItems(headerPreferences.zones);
const hasBoardShortcuts = allHeaderItems.some((item) => item.type === "board");
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(undefined, {
enabled: hasBoardShortcuts,
staleTime: 60_000,
});
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
return (
<BoardSwitcher>
{(boardSwitcher) => {
const renderItems = (items: HeaderItem[]) =>
items.map((item) => (
<HeaderItem
key={getHeaderItemKey(item)}
item={item}
logo={logo}
logoWithTitle={logoWithTitle}
boardEditAction={boardEditAction}
boardSettingsAction={boardSettingsAction}
avatar={avatar}
userId={userId}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
searchDisplay={headerPreferences.searchDisplay}
logoDisplay={headerPreferences.logoDisplay}
boardSwitcher={boardSwitcher}
assistant={assistant}
openDockerModal={openDockerModal}
toggleColorScheme={toggleColorScheme}
label={(key) => t(`items.${key}` as never)}
boardUnavailableLabel={t("items.boardUnavailable")}
/>
));
return (
<>
{headerPreferences.visible ? (
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
className={classes.header}
data-advanced-focus-background
data-app-shell-header
>
<div className={classes.content}>
<div className={classes.zone} data-zone="left">
{hasNavigation ? <ClientBurger /> : null}
{renderItems(headerPreferences.zones.left)}
</div>
<div className={classes.zone} data-zone="center">
{renderItems(headerPreferences.zones.center)}
</div>
<div className={classes.zone} data-zone="right">
{renderItems(headerPreferences.zones.right)}
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
</div>
</div>
</AppShellHeader>
) : (
<FloatingHeaderControls>
{hasNavigation ? <ClientBurger /> : null}
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</FloatingHeaderControls>
)}
<LazySpotlight />
</>
);
}}
</BoardSwitcher>
);
};
const FloatingHeaderControls = ({ children }: { children: ReactNode }) => {
const [isIntroComplete, setIsIntroComplete] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const isInteractingRef = useRef(false);
const dismissTimeoutRef = useRef<number | null>(null);
const clearDismissTimeout = () => {
if (dismissTimeoutRef.current === null) return;
window.clearTimeout(dismissTimeoutRef.current);
dismissTimeoutRef.current = null;
};
const dismissSoon = () => {
if (!isIntroComplete) return;
clearDismissTimeout();
dismissTimeoutRef.current = window.setTimeout(() => {
if (!isInteractingRef.current) setIsVisible(false);
}, floatingControlsDismissDelayMs);
};
const revealFromCorner = () => {
if (!isIntroComplete) return;
clearDismissTimeout();
setIsVisible(true);
};
const startInteraction = () => {
isInteractingRef.current = true;
clearDismissTimeout();
setIsVisible(true);
};
const endInteraction = () => {
isInteractingRef.current = false;
dismissSoon();
};
useEffect(() => {
const introTimeout = window.setTimeout(() => {
setIsIntroComplete(true);
if (!isInteractingRef.current) setIsVisible(false);
}, floatingControlsIntroDurationMs);
return () => {
window.clearTimeout(introTimeout);
if (dismissTimeoutRef.current !== null) window.clearTimeout(dismissTimeoutRef.current);
};
}, []);
return (
<>
<div
className={classes.floatingControlsCorner}
data-active={isIntroComplete || undefined}
aria-hidden
onPointerEnter={revealFromCorner}
onPointerMove={revealFromCorner}
onPointerDown={revealFromCorner}
onPointerLeave={dismissSoon}
/>
<Group
className={classes.floatingControls}
data-visible={isVisible || undefined}
gap={4}
wrap="nowrap"
onPointerEnter={startInteraction}
onPointerLeave={endInteraction}
onFocusCapture={startInteraction}
onBlurCapture={(event) => {
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
endInteraction();
}}
>
{children}
</Group>
</>
);
};
interface HeaderItemProps {
item: HeaderItem;
logo: ReactNode;
logoWithTitle: ReactNode;
boardEditAction: ReactNode;
boardSettingsAction: ReactNode;
avatar: ReactNode;
userId: string | null;
isAdmin: boolean;
isDockerEnabled: boolean;
board: HeaderBoard | undefined;
searchDisplay: "input" | "icon";
logoDisplay: "logo" | "logoAndText";
boardSwitcher: BoardSwitcherControls;
assistant: ReturnType<typeof useOptionalHomarrAssistant>;
openDockerModal: () => void;
toggleColorScheme: () => void;
label: (key: string) => string;
boardUnavailableLabel: string;
}
const HeaderItem = ({
item,
logo,
logoWithTitle,
boardEditAction,
boardSettingsAction,
avatar,
userId,
isAdmin,
isDockerEnabled,
board,
searchDisplay,
logoDisplay,
boardSwitcher,
assistant,
openDockerModal,
toggleColorScheme,
label,
boardUnavailableLabel,
}: HeaderItemProps) => {
if (item.type === "board") {
if (!board) {
return (
<Tooltip label={boardUnavailableLabel}>
<HeaderButton aria-label={boardUnavailableLabel} disabled>
<IconLayoutDashboard size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
return (
<Tooltip label={board.name}>
<HeaderButton href={`/boards/${encodeURIComponent(board.name)}`} aria-label={board.name}>
<Avatar src={board.logoImageUrl} size={22} radius="sm">
<IconLayoutDashboard size={17} stroke={1.5} />
</Avatar>
</HeaderButton>
</Tooltip>
);
}
if (item.id === "logo") {
return (
<HeaderLogo
display={logoDisplay}
logo={logo}
logoWithTitle={logoWithTitle}
label={label("logo")}
/>
);
}
if (item.id === "boardEdit") return boardEditAction;
if (item.id === "boardSettings") return boardSettingsAction;
if (item.id === "search") {
return (
<TourTarget id="board-search">
<div className={classes.search} data-display={searchDisplay}>
{searchDisplay === "input" ? (
<>
<DesktopSearchInput />
<MobileSearchButton />
</>
) : (
<MobileSearchButton alwaysVisible />
)}
</div>
</TourTarget>
);
}
if (item.id === "home") {
return (
<Tooltip label={label("home")}>
<HeaderButton href="/boards" aria-label={label("home")}>
<IconHome size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
if (item.id === "boardSwitcher") {
return (
<Tooltip label={label("boardSwitcher")}>
<HeaderButton
aria-label={label("boardSwitcher")}
onClick={boardSwitcher.open}
onFocus={boardSwitcher.preload}
onPointerEnter={boardSwitcher.preload}
>
<IconReplace size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
if (item.id === "assistant") {
if (!assistant?.enabled) return null;
return (
<Tooltip label={label("assistant")}>
<Indicator
inline
disabled={!assistant.isRunning && assistant.unreadCount === 0}
color="red"
size={16}
label={assistant.isRunning ? <Loader type="bars" color="white" size={8} /> : assistant.unreadCount}
>
<HeaderButton aria-label={label("assistant")} onClick={assistant.open}>
<IconRobot size={20} stroke={1.5} />
</HeaderButton>
</Indicator>
</Tooltip>
);
}
if (item.id === "docker") {
if (!isDockerEnabled) return null;
return (
<Tooltip label={invariantTechnicalLabels.docker}>
<HeaderButton aria-label={invariantTechnicalLabels.docker} onClick={openDockerModal}>
<IconBrandDocker size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
if (item.id === "settings") {
const href = userId ? `/manage/users/${userId}/general` : "/auth/login";
return (
<Tooltip label={label("settings")}>
<HeaderButton href={href} aria-label={label("settings")}>
<IconSettings size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
if (item.id === "themeToggle") {
return (
<Tooltip label={label("themeToggle")}>
<HeaderButton aria-label={label("themeToggle")} onClick={toggleColorScheme}>
<IconSunMoon size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
return (
<TourTarget id="board-user-menu">
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</TourTarget>
);
};
@@ -0,0 +1,22 @@
.root {
display: flex;
align-items: center;
flex: 0 0 auto;
border-radius: var(--mantine-radius-md);
}
.root[data-display="logo"] {
justify-content: center;
width: calc(var(--app-shell-header-height) - 0.5rem);
height: calc(var(--app-shell-header-height) - 0.5rem);
overflow: hidden;
}
.root[data-display="logo"] img {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: calc(var(--app-shell-header-height) - 0.5rem);
object-fit: contain;
}
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import { UnstyledButton } from "@mantine/core";
import { Link } from "@homarr/ui";
import classes from "./header-logo.module.css";
interface HeaderLogoProps {
display: "logo" | "logoAndText";
logo: ReactNode;
logoWithTitle: ReactNode;
label: string;
}
export const HeaderLogo = ({ display, logo, logoWithTitle, label }: HeaderLogoProps) => {
let content = logo;
if (display === "logoAndText") content = logoWithTitle;
return (
<UnstyledButton
component={Link}
href="/"
className={classes.root}
data-display={display}
aria-label={label}
>
{content}
</UnstyledButton>
);
};
@@ -28,12 +28,14 @@ export const DesktopSearchInput = () => {
);
};
export const MobileSearchButton = () => {
export const MobileSearchButton = ({ alwaysVisible = false }: { alwaysVisible?: boolean }) => {
const t = useI18n();
return (
<HeaderButton
data-homarr-dev-benchmark-interaction="search"
onClick={() => openSpotlight()}
className={classes.mobileSearch}
className={alwaysVisible ? undefined : classes.mobileSearch}
aria-label={t("search.placeholder")}
>
<IconSearch size={20} stroke={1.5} />
</HeaderButton>
@@ -8,18 +8,22 @@ import { useHotkeys } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
import { UpdateIndicator } from "./update";
interface UserButtonClientProps {
avatar: ReactNode;
isAdmin: boolean;
isDockerEnabled: boolean;
boardSwitcher: BoardSwitcherControls;
}
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled, boardSwitcher }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const t = useI18n("common.userAvatar.menu");
const session = useSession();
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -45,8 +49,8 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
return (
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled} boardSwitcher={boardSwitcher}>
<UnstyledButton aria-label={t("open")}>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
{avatar}
</UpdateIndicator>
@@ -2,13 +2,19 @@ import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { CurrentUserAvatar } from "~/components/user-avatar";
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
import { UserButtonClient } from "./user-client";
export const UserButton = async () => {
export const UserButton = async ({ boardSwitcher }: { boardSwitcher: BoardSwitcherControls }) => {
const session = await auth();
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
return (
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
<UserButtonClient
avatar={<CurrentUserAvatar size="md" />}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
);
};
@@ -1,8 +1,9 @@
"use client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings";
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
import { homarrLogoPath } from "./constants";
import type { LogoWithTitleProps } from "./logo";
import { Logo, LogoWithTitle } from "./logo";
@@ -12,8 +13,9 @@ interface LogoProps {
const useImageOptions = () => {
const board = useRequiredBoard();
const { branding } = useSettings();
return {
src: board.logoImageUrl ?? homarrLogoPath,
src: board.logoImageUrl?.trim() || branding.logoImageUrl?.trim() || homarrLogoPath,
alt: "Board logo",
shouldUseNextImage: false,
};
@@ -31,12 +33,13 @@ interface CommonLogoWithTitleProps {
export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
const board = useRequiredBoard();
const { branding } = useSettings();
const imageOptions = useImageOptions();
return (
<LogoWithTitle
size={size}
hideTitleOnMobile={hideTitleOnMobile}
title={board.pageTitle ?? homarrPageTitle}
title={board.pageTitle ?? branding.appName}
image={imageOptions}
/>
);
@@ -0,0 +1 @@
export const homarrLogoPath = "/logo/logo.png";
@@ -1,25 +1,39 @@
"use client";
import { useSettings } from "@homarr/settings";
import type { LogoWithTitleProps } from "./logo";
import { Logo, LogoWithTitle } from "./logo";
import { homarrLogoPath } from "./constants";
interface LogoProps {
size: number;
}
export const homarrLogoPath = "/logo/logo.png";
export const homarrPageTitle = "Homarr";
const imageOptions = {
src: homarrLogoPath,
alt: "Homarr logo",
shouldUseNextImage: true,
const useBrandLogo = () => {
const { branding } = useSettings();
const configuredLogoUrl = branding.logoImageUrl?.trim();
return {
title: branding.appName,
image: {
src: configuredLogoUrl || homarrLogoPath,
alt: `${branding.appName} logo`,
shouldUseNextImage: !configuredLogoUrl,
},
};
};
export const HomarrLogo = ({ size }: LogoProps) => <Logo size={size} {...imageOptions} />;
export const HomarrLogo = ({ size }: LogoProps) => {
const brand = useBrandLogo();
return <Logo size={size} {...brand.image} />;
};
interface CommonLogoWithTitleProps {
size: LogoWithTitleProps["size"];
hideTitleOnMobile?: boolean;
}
export const HomarrLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
return <LogoWithTitle size={size} title={homarrPageTitle} image={imageOptions} />;
export const HomarrLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
const brand = useBrandLogo();
return <LogoWithTitle size={size} title={brand.title} image={brand.image} hideTitleOnMobile={hideTitleOnMobile} />;
};
@@ -9,14 +9,18 @@ interface LogoProps {
shouldUseNextImage?: boolean;
}
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) =>
shouldUseNextImage ? (
<Image className="logo" src={src} alt={alt} width={size} height={size} />
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) => {
const normalizedSrc = src.trim();
if (normalizedSrc.length === 0) return null;
return shouldUseNextImage ? (
<Image className="logo" src={normalizedSrc} alt={alt} width={size} height={size} />
) : (
// we only want to use next/image for logos that we are sure will be preloaded and are allowed
// eslint-disable-next-line @next/next/no-img-element
<img className="logo" src={src} alt={alt} width={size} height={size} />
<img className="logo" src={normalizedSrc} alt={alt} width={size} height={size} />
);
};
const logoWithTitleSizes = {
lg: { logoSize: 48, titleOrder: 1 },
@@ -15,7 +15,17 @@ export const CommonNavLink = (props: ClientNavigationLink) =>
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
id ? <TourTarget id={id}>{children}</TourTarget> : children;
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
const pathMatches = (pathname: string, href: string, exact = false) => {
if (pathname === href) return true;
if (exact) return false;
return pathname.startsWith(`${href}/`);
};
const getMostSpecificMatchingHref = (pathname: string, items: NavigationLinkHref[]) =>
items
.filter((item) => pathMatches(pathname, item.href, item.exact))
.toSorted((first, second) => second.href.length - first.href.length)
.at(0)?.href;
const useClientPathname = () => {
const pathname = usePathname();
@@ -31,6 +41,7 @@ const useClientPathname = () => {
const NavLinkHref = (props: NavigationLinkHref) => {
const { pathname, isClient } = useClientPathname();
const tourId = props["data-onboarding-tour-id"];
const isActive = props.active ?? (isClient && pathMatches(pathname, props.href, props.exact));
const link = props.external ? (
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
) : (
@@ -39,7 +50,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
label={props.label}
leftSection={props.icon}
href={props.href}
active={isClient && pathMatches(pathname, props.href)}
active={isActive}
/>
);
return withOptionalTourTarget(tourId, link);
@@ -47,11 +58,18 @@ const NavLinkHref = (props: NavigationLinkHref) => {
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const { pathname, isClient } = useClientPathname();
const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href));
const activeItemHref = getMostSpecificMatchingHref(pathname, props.items);
const isActive = isClient && activeItemHref !== undefined;
const [opened, setOpened] = useState(false);
useEffect(() => {
if (isActive) setOpened(true);
}, [isActive]);
const nav = (
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
<NavLink label={props.label} leftSection={props.icon} active={isActive} opened={opened} onChange={setOpened}>
{props.items.map((item) => (
<NavLinkHref key={item.label} {...item} />
<NavLinkHref key={item.label} {...item} active={isClient && activeItemHref === item.href} />
))}
</NavLink>
);
@@ -67,6 +85,8 @@ interface CommonNavigationLinkProps {
interface NavigationLinkHref extends CommonNavigationLinkProps {
href: string;
external?: boolean;
exact?: boolean;
active?: boolean;
}
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
items: NavigationLinkHref[];
@@ -72,6 +72,7 @@ interface CommonNavigationLinkProps {
interface NavigationLinkHref extends CommonNavigationLinkProps {
href: string;
external?: boolean;
exact?: boolean;
}
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
items: NavigationLinkHref[];
+4 -1
View File
@@ -3,6 +3,7 @@
import type { PropsWithChildren } from "react";
import { AppShell } from "@mantine/core";
import { useAtomValue } from "jotai";
import { useSettings } from "@homarr/settings";
import { useOptionalBackgroundProps } from "./background";
import { appShellHeaderHeight } from "./constants";
@@ -20,11 +21,13 @@ export const ClientShell = ({
}: PropsWithChildren<ClientShellProps>) => {
const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps();
const { headerPreferences } = useSettings();
const isHeaderVisible = hasHeader && headerPreferences.visible;
return (
<AppShell
{...backgroundProps}
header={hasHeader ? { height: appShellHeaderHeight } : undefined}
header={isHeaderVisible ? { height: appShellHeaderHeight } : undefined}
navbar={
hasNavigation
? {
@@ -8,10 +8,9 @@ import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
import type { FloatingPosition } from "@mantine/core";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
interface TourShellProps extends PropsWithChildren {
steps: OnboardingTourStep[];
started: boolean;
@@ -79,6 +78,7 @@ const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
const tCommon = useI18n("common.action");
const tTour = useI18n("onboardingTour");
const { branding } = useSettings();
const router = useRouter();
const forwardActionRef = useRef<(() => void) | null>(null);
@@ -145,7 +145,13 @@ export const TourShell = ({ steps, started, onEnd, stepRoutes, position, childre
</Group>
{stepIndexFromController(controller) === 0 && (
<Center py="xs">
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" />
<Image
src={branding.logoImageUrl ?? "/logo/logo.png"}
alt={`${branding.appName} logo`}
w={64}
h={64}
fit="contain"
/>
</Center>
)}
</>
+101 -104
View File
@@ -23,7 +23,7 @@ import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
import { BoardSwitcher } from "./board/board-switcher";
import type { BoardSwitcherControls } from "./board/board-switcher";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
@@ -33,6 +33,7 @@ interface UserAvatarMenuProps {
children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean;
boardSwitcher: BoardSwitcherControls;
}
const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
@@ -41,7 +42,7 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
.map((key) => (key === "mod" ? modifierLabel : `${key.charAt(0).toUpperCase()}${key.slice(1)}`))
.join(" + ");
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, boardSwitcher }: UserAvatarMenuProps) => {
const t = useI18n("common.userAvatar.menu");
const tBoard = useI18n("board");
const session = useSession();
@@ -59,111 +60,107 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
}, [logoutUrl]);
return (
<BoardSwitcher>
{({ open, preload, hotkey }) => (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
<Menu.Item
leftSection={<IconReplace size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkey, t("modifier"))}</Kbd>}
onClick={open}
onFocus={preload}
onPointerEnter={preload}
>
{tBoard("action.switch")}
</Menu.Item>
<Menu.Divider />
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
<Menu.Item
leftSection={<IconReplace size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
onClick={boardSwitcher.open}
onFocus={boardSwitcher.preload}
onPointerEnter={boardSwitcher.preload}
>
{tBoard("action.switch")}
</Menu.Item>
<Menu.Divider />
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentColorSchemeCombobox />
</Menu.Item>
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
<Menu.Divider />
{Boolean(session.data) && (
<>
{assistant?.enabled && (
<Menu.Item
leftSection={
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
}
rightSection={
assistant.unreadCount > 0 ? (
<Badge size="sm" variant="filled" color="red" circle>
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
</Badge>
) : assistant.isRunning ? (
<Text size="xs" c="dimmed">
{t("assistantThinking")}
</Text>
) : (
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
)
}
onClick={assistant.open}
>
{t("assistant")}
</Menu.Item>
)}
<Menu.Item
component={Link}
href={`/manage/users/${session.data?.user.id}/general`}
leftSection={<IconSettings size="1rem" />}
>
{t("preferences")}
</Menu.Item>
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")}
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{invariantTechnicalLabels.docker}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
{session.status === "authenticated" ? (
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
{t("logout")}
</Menu.Item>
) : (
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
{t("login")}
</Menu.Item>
)}
</Menu.Dropdown>
<Menu.Target>
<Indicator
inline
disabled={!assistant?.isRunning && !assistant?.unreadCount}
color="red"
size={20}
offset={4}
label={
assistant?.isRunning ? (
<Loader type="bars" color="white" size={10} />
) : assistant?.unreadCount ? (
assistant.unreadCount > 99 ? (
"99+"
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentColorSchemeCombobox />
</Menu.Item>
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
<Menu.Divider />
{Boolean(session.data) && (
<>
{assistant?.enabled && (
<Menu.Item
leftSection={
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
}
rightSection={
assistant.unreadCount > 0 ? (
<Badge size="sm" variant="filled" color="red" circle>
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
</Badge>
) : assistant.isRunning ? (
<Text size="xs" c="dimmed">
{t("assistantThinking")}
</Text>
) : (
assistant.unreadCount
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
)
) : undefined
}
}
onClick={assistant.open}
>
{t("assistant")}
</Menu.Item>
)}
<Menu.Item
component={Link}
href={`/manage/users/${session.data?.user.id}/general`}
leftSection={<IconSettings size="1rem" />}
>
{children}
</Indicator>
</Menu.Target>
</Menu>
)}
</BoardSwitcher>
{t("preferences")}
</Menu.Item>
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")}
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{invariantTechnicalLabels.docker}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
{session.status === "authenticated" ? (
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
{t("logout")}
</Menu.Item>
) : (
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
{t("login")}
</Menu.Item>
)}
</Menu.Dropdown>
<Menu.Target>
<Indicator
inline
disabled={!assistant?.isRunning && !assistant?.unreadCount}
color="red"
size={20}
offset={4}
label={
assistant?.isRunning ? (
<Loader type="bars" color="white" size={10} />
) : assistant?.unreadCount ? (
assistant.unreadCount > 99 ? (
"99+"
) : (
assistant.unreadCount
)
) : undefined
}
>
{children}
</Indicator>
</Menu.Target>
</Menu>
);
};
-1
View File
@@ -1 +0,0 @@
export const createMetaTitle = (name: string) => `${name} • Homarr`;
+43
View File
@@ -0,0 +1,43 @@
import type { MantineColorsTuple, MantineThemeOverride } from "@mantine/core";
import { createTheme, darken, lighten, mergeThemeOverrides } from "@mantine/core";
import type { ServerSettings } from "@homarr/server-settings";
import { theme as homarrTheme } from "@homarr/ui";
export const generateColorScale = (hex: string) => {
const lightnessSteps = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
const colors = lightnessSteps.map((lightness) => {
if (lightness < 0) return lighten(hex, -lightness);
return darken(hex, lightness);
});
return colors.map((color) => {
const channels = color.split("(")[1]?.replaceAll(" ", "").replace(")", "").split(",");
if (!channels) return hex;
const value = channels
.map((channel) => parseInt(channel, 10))
.slice(0, 3)
.map((channel) => channel.toString(16).padStart(2, "0"))
.join("");
return `#${value}`;
}) as unknown as MantineColorsTuple;
};
export const createBrandTheme = (branding: ServerSettings["branding"]): MantineThemeOverride =>
mergeThemeOverrides(
homarrTheme,
createTheme({
colors: {
primaryColor: generateColorScale(branding.primaryColor),
secondaryColor: generateColorScale(branding.secondaryColor),
},
primaryColor: "primaryColor",
defaultRadius: branding.defaultRadius,
components: {
ActionIcon: { defaultProps: { radius: branding.defaultRadius } },
Button: { defaultProps: { radius: branding.defaultRadius } },
Card: { defaultProps: { radius: branding.defaultRadius } },
Paper: { defaultProps: { radius: branding.defaultRadius } },
},
}),
);