feat: add configurable headers and instance branding

This commit is contained in:
ajnart committed 2026-08-23 22:28:13 +02:00
1 parent ec295fe26e
commit 86550aadc9
93 files changed
+28223 -567

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 } 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,30 @@ 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 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}
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,6 +2,7 @@ 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";
@@ -19,17 +20,32 @@ 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 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}
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";
@@ -103,18 +104,26 @@ 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 }
: createMetaTitle(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,
},
},
};
@@ -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,6 +8,7 @@ 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";
@@ -21,20 +22,26 @@ interface Props {
export const GeneralSettingsContent = ({ board, form }: Props) => {
const t = useI18n("board");
const { branding } = useSettings();
const defaultMetaTitle = createMetaTitle(t("content.metaTitle", { boardName: board.name }), branding.appName);
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 +50,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 +58,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 +79,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 } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
import { env } from "~/env";
@@ -105,7 +106,7 @@ export const createBoardLayout = <TParams extends Params>({
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
logo={<BoardLogo size={appShellLogoHeight} />}
actions={headerActions}
hasNavigation={false}
/>
+8 -2
View File
@@ -2,6 +2,7 @@ import { headers } from "next/headers";
import { getOnboardingClaimTokenFromCookieHeader, isOnboardingClaimValidAsync } from "@homarr/api/onboarding-claim";
import { normalizeOnboardingStep } from "@homarr/api/onboarding-step";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { extractBaseUrlFromHeaders } from "@homarr/common";
@@ -19,11 +20,12 @@ import { getPackageVersion } from "~/versions/package-reader";
import { AssistantConfiguration } from "../manage/assistant/_components/assistant-configuration";
export default async function InitPage() {
const [state, session, requestHeaders, firstUser] = await Promise.all([
const [state, session, requestHeaders, firstUser, serverSettings] = await Promise.all([
db.query.onboarding.findFirst(),
auth(),
headers(),
db.query.users.findFirst({ columns: { id: true } }),
getRscServerSettingsAsync(),
]);
const canConfigurePrivileged = session?.user.permissions.includes("admin") ?? false;
const onboardingClaim = getOnboardingClaimTokenFromCookieHeader(requestHeaders.get("cookie"));
@@ -72,7 +74,11 @@ export default async function InitPage() {
availableBoards: availableBoards.map(({ id, name }) => ({ id, name })),
}}
sqliteRestore={databaseDriver === "sqlite" ? <DatabaseRestoreFlow variant="standalone" /> : undefined}
assistantConfiguration={canConfigurePrivileged ? <AssistantConfiguration /> : undefined}
assistantConfiguration={
canConfigurePrivileged && serverSettings.featureControls.assistantEnabled ? (
<AssistantConfiguration />
) : undefined
}
/>
);
}
+56 -38
View File
@@ -53,35 +53,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",
},
});
const defaultDescription =
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.";
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
// eslint-disable-next-line no-restricted-syntax
export const generateMetadata = async (): Promise<Metadata> => {
const [serverSettings, colorScheme] = await Promise.all([getRscServerSettingsAsync(), getCurrentColorSchemeAsync()]);
const { appName, faviconImageUrl, logoImageUrl } = serverSettings.branding;
const logo = logoImageUrl ?? "/logo/logo.png";
const favicon = faviconImageUrl ?? logo;
return {
title: {
default: appName,
template: `%s • ${appName}`,
},
description: defaultDescription,
openGraph: {
title: `${appName} Dashboard`,
description: defaultDescription,
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",
},
};
};
// eslint-disable-next-line no-restricted-syntax
export const generateViewport = async (): Promise<Viewport> => {
const serverSettings = await getRscServerSettingsAsync();
return { themeColor: serverSettings.branding.primaryColor };
};
export default async function Layout(props: {
@@ -94,6 +104,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 +113,21 @@ 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> = Promise.all([
sessionPromise,
serverSettingsPromise,
]).then(([session, serverSettings]) => {
if (!serverSettings.featureControls.assistantEnabled) return "disabled";
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 +162,8 @@ export default async function Layout(props: {
},
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
user: { enableGravatar: serverSettings.user.enableGravatar },
branding: serverSettings.branding,
featureControls: serverSettings.featureControls,
}}
{...innerProps}
/>
@@ -156,7 +172,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">
@@ -15,8 +17,15 @@ export const HeroBanner = async () => {
{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>
<Image
src={logoImageUrl ?? "/logo/logo.png"}
alt={`${appName} logo`}
w={{ base: 32, md: 40 }}
h={{ base: 32, md: 40 }}
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,7 +37,7 @@ 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";
@@ -1,23 +1,25 @@
import { notFound } from "next/navigation";
import { Text } from "@mantine/core";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { createMetaTitle } from "~/metadata";
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: createMetaTitle(t("title")),
};
}
export default async function AssistantPage() {
const session = await auth();
const [session, serverSettings] = await Promise.all([auth(), getRscServerSettingsAsync()]);
if (!session?.user.permissions.includes("admin")) {
if (!serverSettings.featureControls.assistantEnabled || !session?.user.permissions.includes("admin")) {
notFound();
}
@@ -29,6 +29,7 @@ import {
} from "@tabler/icons-react";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -38,7 +39,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";
@@ -61,11 +62,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
return false;
}
});
const [t, tEntities, session, shouldRunManageTour] = await Promise.all([
const [t, tEntities, session, shouldRunManageTour, serverSettings] = await Promise.all([
getI18n("management.navbar"),
getI18n("common.entity"),
sessionPromise,
shouldRunManageTourPromise,
getRscServerSettingsAsync(),
]);
const appsAccess = getAppsSectionAccess(session);
const integrationsAccess = await getIntegrationsSectionAccessAsync(session);
@@ -74,6 +76,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.home"),
icon: IconHomeFilled,
href: "/manage",
exact: true,
"data-onboarding-tour-id": "manage-welcome",
},
{
@@ -200,7 +203,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.assistant"),
href: "/manage/assistant",
icon: IconRobot,
hidden: !session?.user.permissions.includes("admin"),
hidden: !serverSettings.featureControls.assistantEnabled || !session?.user.permissions.includes("admin"),
},
{
label: t("items.settings"),
@@ -241,7 +244,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
{
label: t("items.about"),
icon: homarrLogoPath,
icon: serverSettings.branding.logoImageUrl ?? homarrLogoPath,
href: "/manage/about",
},
];
@@ -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")}
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")}
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 never),
]),
) 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")}
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={{ base: 1, xs: 3 }} spacing="xs" verticalSpacing="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}` as never)}
</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>
);
};
@@ -0,0 +1,39 @@
"use client";
import { SimpleGrid, Switch } from "@mantine/core";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
interface FeatureControlsSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const FeatureControlsSettingsForm = ({ form }: FeatureControlsSettingsFormProps) => {
const t = useI18n("management.page.settings.section.featureControls");
return (
<SectionCard title={t("title")}>
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md" verticalSpacing="sm">
<Switch
label={t("assistant.label")}
description={t("assistant.description")}
{...form.getInputProps("featureControls.assistantEnabled", { type: "checkbox" })}
/>
<Switch
label={t("boardSwitcher.label")}
description={t("boardSwitcher.description")}
{...form.getInputProps("featureControls.boardSwitcherEnabled", { type: "checkbox" })}
/>
<Switch
label={t("widgetContextMenu.label")}
description={t("widgetContextMenu.description")}
{...form.getInputProps("featureControls.widgetContextMenuEnabled", { type: "checkbox" })}
/>
</SimpleGrid>
</SectionCard>
);
};
@@ -12,14 +12,17 @@ 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, featureControlsServerSettingsSchema } 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 { FeatureControlsSettingsForm } from "./feature-controls-settings-form";
import { SearchSettingsForm } from "./search-settings-form";
import { UserSettingsForm } from "./user-settings-form";
@@ -37,6 +40,8 @@ const settingsFormSchema = z.object({
defaultSearchEngineId: z.string().nullable(),
defaultColorScheme: z.enum(colorSchemes),
defaultLocale: z.string(),
branding: brandingServerSettingsSchema,
featureControls: featureControlsServerSettingsSchema,
});
export type FormValues = z.infer<typeof settingsFormSchema>;
@@ -55,6 +60,8 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
defaultColorScheme: initialData.appearance.defaultColorScheme,
defaultLocale: initialData.culture.defaultLocale,
branding: initialData.branding,
featureControls: initialData.featureControls,
});
interface SettingsFormProps {
@@ -137,6 +144,16 @@ 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,
},
{
settingsKey: "featureControls",
when: changed("featureControls"),
value: values.featureControls,
},
];
const promises = groups
@@ -176,6 +193,8 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
<UserSettingsForm form={form} />
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
<AppearanceSettingsForm form={form} />
<BrandingSettingsForm form={form} />
<FeatureControlsSettingsForm form={form} />
<CultureSettingsForm form={form} />
<AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} />
@@ -5,14 +5,14 @@ import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { createMetaTitle } from "~/metadata";
import { SettingsForm } from "./_components/settings-form";
export async function generateMetadata() {
const t = await getI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
title: createMetaTitle(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,342 @@
.composer {
width: 100%;
overflow: hidden;
background:
radial-gradient(
ellipse 70% 42% at 50% 0%,
color-mix(in srgb, var(--mantine-primary-color-light) 32%, transparent),
transparent 72%
),
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: 20rem;
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: 100%;
min-height: 15rem;
overflow: hidden;
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-lg);
transition:
width 180ms ease,
min-height 180ms ease;
}
.stage[data-size="mobile"] .device {
width: min(100%, 24rem);
min-height: 18rem;
}
.previewHeader {
position: relative;
z-index: 1;
height: 5rem;
border-bottom: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
direction: ltr;
}
.stripScroller {
width: 100%;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-color: var(--mantine-color-default-border) transparent;
scrollbar-width: thin;
}
.sortableStrip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--mantine-spacing-sm);
width: 100%;
min-width: 100%;
height: 100%;
padding: 0.35rem var(--mantine-spacing-sm) 0.45rem;
white-space: nowrap;
}
.zoneSegment {
display: grid;
grid-template-rows: 1.25rem 2.5rem;
gap: 0.3rem;
min-width: 0;
align-content: center;
}
.zoneDelimiter {
display: grid;
align-items: center;
width: 100%;
min-width: 0;
min-height: 1.25rem;
padding-inline: 0.15rem;
border-radius: var(--mantine-radius-sm);
transition: background-color 120ms ease;
}
.zoneDelimiter :global(.mantine-Divider-label) {
padding-inline: 0.25rem;
color: var(--mantine-color-dimmed);
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.zoneDelimiter[data-over="true"] {
background: var(--mantine-primary-color-light);
}
.zoneDelimiter[data-over="true"] :global(.mantine-Divider-label) {
color: var(--mantine-primary-color-filled);
}
.zoneControls {
display: flex;
align-items: center;
gap: 0.3rem;
min-width: 0;
height: 2.5rem;
padding: 0.125rem;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.zoneControls::-webkit-scrollbar {
display: none;
}
.zoneSegment[data-zone="center"] .zoneControls {
justify-content: safe center;
}
.zoneSegment[data-zone="right"] .zoneControls {
justify-content: safe flex-end;
}
.zoneTail {
display: grid;
place-items: center;
width: 1.5rem;
height: 2.25rem;
flex: 0 0 1.5rem;
border-radius: var(--mantine-radius-sm);
transition: background-color 120ms ease;
}
.zoneTailMarker {
width: 2px;
height: 1rem;
border-radius: var(--mantine-radius-xl);
background: var(--mantine-color-default-border);
transition:
height 120ms ease,
background-color 120ms ease;
}
.zoneTail[data-over="true"] {
background: var(--mantine-primary-color-light);
}
.zoneTail[data-over="true"] .zoneTailMarker {
height: 1.5rem;
background: var(--mantine-primary-color-filled);
}
.sortableItem {
flex: 0 0 auto;
}
.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: 9rem;
}
.itemButton:hover,
.itemButton:focus-visible,
.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-dimmed);
}
.searchPreview[data-input="true"] {
grid-template-columns: auto minmax(0, 1fr);
gap: var(--mantine-spacing-xs);
padding-inline: var(--mantine-spacing-sm);
font-size: var(--mantine-font-size-sm);
text-align: start;
}
.searchPreview span {
overflow: hidden;
text-overflow: ellipsis;
}
.mobileBurger {
display: none;
width: 2.25rem;
height: 2.25rem;
gap: 3px;
place-content: center;
flex: 0 0 auto;
}
.mobileBurger span {
display: block;
width: 1rem;
height: 1px;
background: var(--mantine-color-text);
}
.stage[data-size="mobile"] .mobileBurger {
display: grid;
}
.contextActions {
display: flex;
gap: 5px;
align-items: center;
height: 2.25rem;
flex: 0 0 auto;
padding-inline: 0.35rem;
}
.contextActions span {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--mantine-color-dimmed);
opacity: 0.45;
}
.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: 11.25rem;
overflow: hidden;
opacity: 0.86;
}
.dashboardLayout {
height: 100%;
aspect-ratio: auto;
}
.stage[data-size="mobile"] .sortableStrip {
gap: var(--mantine-spacing-xs);
padding-inline: var(--mantine-spacing-xs);
}
.stage[data-size="mobile"] .zoneDelimiter :global(.mantine-Divider-label) {
font-size: 0.5rem;
}
.stage[data-size="mobile"] .dashboardPreview {
height: 14.25rem;
}
.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,
.zoneDelimiter,
.zoneTail,
.zoneTailMarker,
.itemButton {
transition: none;
}
}
@@ -0,0 +1,682 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import { useId, useMemo, useState } from "react";
import type { DragEndEvent, UniqueIdentifier } from "@dnd-kit/core";
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
MouseSensor,
TouchSensor,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import {
ActionIcon,
Avatar,
Box,
Button,
Card,
Divider,
Group,
Menu,
Paper,
SegmentedControl,
Stack,
Text,
ThemeIcon,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import {
IconBrandDocker,
IconEye,
IconEyeOff,
IconHome,
IconLayoutDashboard,
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,
headerZoneIds,
} 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;
}
type PreviewSize = "desktop" | "mobile";
export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHomeBoardId }: HeaderComposerProps) => {
const dndContextId = useId();
const [previewSize, setPreviewSize] = useState<PreviewSize>("desktop");
const [draggedItemKey, setDraggedItemKey] = useState<string | null>(null);
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const { featureControls } = useSettings();
const t = useI18n("management.page.user.setting.general.header");
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 5 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
keyboardCodes: { start: ["Space"], cancel: ["Escape"], end: ["Space"] },
}),
);
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
const activeItems = getHeaderItems(value.zones);
const visibleItems = activeItems.filter((item) => isHeaderItemAvailable(item, featureControls));
const visibleItemKeys = visibleItems.map(getHeaderItemKey);
const activeItemKeySet = new Set(activeItems.map(getHeaderItemKey));
const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => {
const item = createBuiltinHeaderItem(itemId);
return isHeaderItemAvailable(item, featureControls) && !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);
let previewBoardId = homeBoardId;
if (previewSize === "mobile") previewBoardId = mobileHomeBoardId ?? homeBoardId;
const previewBoard = boardsById.get(previewBoardId ?? "") ?? boards.at(0);
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 (item.type === "builtin" && item.id === "user") 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 = ({ active, over }: DragEndEvent) => {
setDraggedItemKey(null);
if (!over) return;
const item = findHeaderItem(activeItems, active.id);
if (!item) return;
const sourceZone = getHeaderItemZone(value.zones, item);
const targetZone = getDropZone(value.zones, over.id);
if (!sourceZone || !targetZone) return;
const targetItem = findHeaderItem(activeItems, over.id);
let targetIndex = 0;
if (getDropZoneTailId(targetZone) === over.id) targetIndex = value.zones[targetZone].length;
else if (targetItem)
targetIndex = value.zones[targetZone].findIndex((candidate) => isSameItem(candidate, targetItem));
if (targetIndex < 0) return;
updateZones(moveHeaderItem(value.zones, item, targetZone, targetIndex));
};
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">
<SegmentedControl
size="xs"
value={previewSize}
onChange={(next) => setPreviewSize(next as PreviewSize)}
data={[
{ value: "desktop", label: t("device.desktop") },
{ value: "mobile", label: t("device.mobile") },
]}
/>
<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-size={previewSize} data-visible={value.visible}>
<Paper className={classes.device} radius="lg" withBorder>
{value.visible ? (
<DndContext
id={dndContextId}
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={({ active }) => setDraggedItemKey(String(active.id))}
onDragCancel={() => setDraggedItemKey(null)}
onDragEnd={handleDragEnd}
>
<div className={classes.previewHeader}>
<div className={classes.stripScroller}>
<div className={classes.sortableStrip}>
<SortableContext items={visibleItemKeys} strategy={horizontalListSortingStrategy}>
{headerZoneIds.map((zone) => (
<ZoneSegment
key={zone}
zone={zone}
label={t(`zones.${zone}` as never)}
endLabel={t("action.dropAtEnd", { zone: t(`zones.${zone}` as never) })}
leading={zone === "left" ? <MobileBurger /> : null}
trailing={zone === "right" ? <ContextActions /> : null}
items={value.zones[zone].filter((item) => isHeaderItemAvailable(item, featureControls))}
previewSize={previewSize}
searchDisplay={value.searchDisplay}
selectedItemKey={selectedItemKey}
itemLabel={itemLabel}
boardLogo={(boardId) => boardsById.get(boardId)?.logoImageUrl ?? null}
configureLabel={(item) => t("action.configure", { item: itemLabel(item) })}
onSelect={setSelectedItemKey}
/>
))}
</SortableContext>
</div>
</div>
</div>
<DragOverlay zIndex={1000}>
{draggedItem ? (
<HeaderItemPreview
item={draggedItem}
label={itemLabel(draggedItem)}
compact={previewSize === "mobile"}
searchDisplay={value.searchDisplay}
boardLogo={
draggedItem.type === "board" ? boardsById.get(draggedItem.boardId)?.logoImageUrl : null
}
overlay
/>
) : null}
</DragOverlay>
</DndContext>
) : (
<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 !== "user" ? (
<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.accountAlways")}
</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 ZoneSegmentProps {
zone: HeaderZoneId;
label: string;
endLabel: string;
leading: ReactNode;
trailing: ReactNode;
items: HeaderItem[];
previewSize: PreviewSize;
searchDisplay: HeaderPreferences["searchDisplay"];
selectedItemKey: string | null;
itemLabel: (item: HeaderItem) => string;
boardLogo: (boardId: string) => string | null;
configureLabel: (item: HeaderItem) => string;
onSelect: (itemKey: string) => void;
}
const ZoneSegment = ({
zone,
label,
endLabel,
leading,
trailing,
items,
previewSize,
searchDisplay,
selectedItemKey,
itemLabel,
boardLogo,
configureLabel,
onSelect,
}: ZoneSegmentProps) => (
<section className={classes.zoneSegment} data-zone={zone} aria-label={label}>
<ZoneDelimiter zone={zone} label={label} />
<div className={classes.zoneControls}>
{leading}
{items.map((item) => {
const itemKey = getHeaderItemKey(item);
return (
<SortableHeaderItem
key={itemKey}
item={item}
label={itemLabel(item)}
previewSize={previewSize}
searchDisplay={searchDisplay}
boardLogo={item.type === "board" ? boardLogo(item.boardId) : null}
configureLabel={configureLabel(item)}
selected={selectedItemKey === itemKey}
onSelect={onSelect}
/>
);
})}
<ZoneTail zone={zone} label={endLabel} />
{trailing}
</div>
</section>
);
const ZoneDelimiter = ({ zone, label }: { zone: HeaderZoneId; label: string }) => {
const { isOver, setNodeRef } = useDroppable({ id: getDropZoneId(zone) });
return (
<div ref={setNodeRef} className={classes.zoneDelimiter} data-zone={zone} data-over={isOver || undefined}>
<Divider label={label} labelPosition="center" />
</div>
);
};
const ZoneTail = ({ zone, label }: { zone: HeaderZoneId; label: string }) => {
const { isOver, setNodeRef } = useDroppable({ id: getDropZoneTailId(zone) });
return (
<div ref={setNodeRef} className={classes.zoneTail} data-over={isOver || undefined}>
<VisuallyHidden>{label}</VisuallyHidden>
<span className={classes.zoneTailMarker} aria-hidden />
</div>
);
};
const MobileBurger = () => (
<div className={classes.mobileBurger} aria-hidden>
<span />
<span />
<span />
</div>
);
const ContextActions = () => (
<div className={classes.contextActions} aria-hidden>
<span />
<span />
</div>
);
interface SortableHeaderItemProps {
item: HeaderItem;
label: string;
previewSize: PreviewSize;
searchDisplay: HeaderPreferences["searchDisplay"];
boardLogo: string | null;
configureLabel: string;
selected: boolean;
onSelect: (itemKey: string) => void;
}
const SortableHeaderItem = ({
item,
label,
previewSize,
searchDisplay,
boardLogo,
configureLabel,
selected,
onSelect,
}: SortableHeaderItemProps) => {
const itemKey = getHeaderItemKey(item);
const { attributes, isDragging, listeners, setActivatorNodeRef, setNodeRef, transform, transition } = useSortable({
id: itemKey,
});
const style = {
transition,
transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` : undefined,
opacity: isDragging ? 0.2 : undefined,
} satisfies CSSProperties;
return (
<div ref={setNodeRef} style={style} className={classes.sortableItem} data-selected={selected || undefined}>
<Tooltip label={configureLabel} openDelay={500}>
<button
ref={setActivatorNodeRef}
type="button"
className={classes.itemButton}
data-kind={item.type === "builtin" ? item.id : "board"}
data-compact={previewSize === "mobile" || undefined}
data-wide={
item.type === "builtin" && item.id === "search" && searchDisplay === "input" && previewSize === "desktop"
? true
: undefined
}
aria-label={configureLabel}
onClick={() => onSelect(itemKey)}
{...attributes}
{...listeners}
>
<HeaderItemPreview
item={item}
label={label}
compact={previewSize === "mobile"}
searchDisplay={searchDisplay}
boardLogo={boardLogo}
/>
</button>
</Tooltip>
</div>
);
};
const HeaderItemPreview = ({
item,
label,
compact,
searchDisplay,
boardLogo,
overlay = false,
}: {
item: HeaderItem;
label: string;
compact: boolean;
searchDisplay: HeaderPreferences["searchDisplay"];
boardLogo: string | null | undefined;
overlay?: boolean;
}) => {
const content = (
<PreviewItem item={item} label={label} compact={compact} searchDisplay={searchDisplay} boardLogo={boardLogo} />
);
if (!overlay) return content;
return (
<div
className={classes.dragOverlay}
data-kind={item.type === "builtin" ? item.id : "board"}
data-wide={
item.type === "builtin" && item.id === "search" && searchDisplay === "input" && !compact ? true : undefined
}
>
{content}
</div>
);
};
const PreviewItem = ({
item,
label,
compact,
searchDisplay,
boardLogo,
}: {
item: HeaderItem;
label: string;
compact: boolean;
searchDisplay: HeaderPreferences["searchDisplay"];
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 <HomarrLogo size={26} />;
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,
item: HeaderItem,
targetZone: HeaderZoneId,
targetIndex: number,
): HeaderZones => {
const sourceZone = getHeaderItemZone(zones, item);
if (!sourceZone) return zones;
const sourceItems = zones[sourceZone];
const sourceIndex = sourceItems.findIndex((candidate) => isSameItem(candidate, item));
if (sourceIndex < 0) return zones;
if (sourceZone === targetZone) {
const normalizedTargetIndex = Math.min(targetIndex, sourceItems.length - 1);
if (sourceIndex === normalizedTargetIndex) return zones;
return { ...zones, [sourceZone]: arrayMove(sourceItems, sourceIndex, normalizedTargetIndex) };
}
const targetItems = zones[targetZone];
return {
...zones,
[sourceZone]: sourceItems.filter((candidate) => !isSameItem(candidate, item)),
[targetZone]: [...targetItems.slice(0, targetIndex), item, ...targetItems.slice(targetIndex)],
};
};
const getDropZoneId = (zone: HeaderZoneId) => `header-zone-${zone}`;
const getDropZoneTailId = (zone: HeaderZoneId) => `header-zone-${zone}-end`;
const getDropZone = (zones: HeaderZones, id: UniqueIdentifier): HeaderZoneId | undefined => {
const item = findHeaderItem(getHeaderItems(zones), id);
if (item) return getHeaderItemZone(zones, item);
return headerZoneIds.find((zone) => getDropZoneId(zone) === id || getDropZoneTailId(zone) === id);
};
const findHeaderItem = (items: HeaderItem[], id: UniqueIdentifier | null): HeaderItem | undefined => {
if (id === null) return undefined;
const itemKey = String(id);
return items.find((item) => getHeaderItemKey(item) === itemKey);
};
const isSameItem = (first: HeaderItem, second: HeaderItem) => getHeaderItemKey(first) === getHeaderItemKey(second);
const isHeaderItemAvailable = (
item: HeaderItem,
featureControls: { assistantEnabled: boolean; boardSwitcherEnabled: boolean },
) => {
if (item.type === "board") return true;
if (item.id === "assistant") return featureControls.assistantEnabled;
if (item.id === "boardSwitcher") return featureControls.boardSwitcherEnabled;
return true;
};
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 === "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";
@@ -58,7 +58,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 +71,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">
@@ -18,6 +18,7 @@ import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq } from "@homarr/db";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { assistantConfigurations, assistantThreads } from "@homarr/db/schema";
import {
assistantProviderCanUseOpenRouterServerTools,
@@ -373,6 +374,11 @@ export async function POST(request: Request) {
return Response.json({ error: "Sign in to use Homarr Assistant." }, { status: 401 });
}
const featureControls = await getServerSettingByKeyAsync(db, "featureControls");
if (!featureControls.assistantEnabled) {
return Response.json({ error: "Homarr Assistant is disabled." }, { status: 404 });
}
const requestBody = await request.text();
if (requestBody.length > assistantRequestMaxBytes) {
return Response.json({ error: "The assistant request is too large." }, { status: 413 });
+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,
};
}
@@ -23,9 +23,16 @@ const EnabledAssistantRoot = dynamic(() =>
import("./assistant-provider").then((module) => ({ default: module.EnabledAssistantRoot })),
);
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
const AssistantSpotlightPlaceholder = ({ description }: { description: string }) => {
useRegisterAssistantSpotlightPlaceholder(description);
return null;
};
const DisabledAssistant = ({
children,
description,
discoverable = true,
}: PropsWithChildren<{ description: string; discoverable?: boolean }>) => {
const value = useMemo(
() => ({
enabled: false,
@@ -49,6 +56,7 @@ const DisabledAssistant = ({ children, description }: PropsWithChildren<{ descri
return (
<AssistantContext.Provider value={value}>
{discoverable ? <AssistantSpotlightPlaceholder description={description} /> : null}
<AssistantWidgetRendererProvider renderer={AssistantBoardWidgetLazy}>{children}</AssistantWidgetRendererProvider>
</AssistantContext.Provider>
);
@@ -60,7 +68,7 @@ const DisabledAssistant = ({ children, description }: PropsWithChildren<{ descri
* `unauthenticated` is kept apart from `unconfigured` because the server cannot check availability
* for a signed-out visitor, and telling them the instance is unconfigured would be a guess.
*/
export type AssistantAvailability = "enabled" | "unconfigured" | "unauthenticated" | "error";
export type AssistantAvailability = "enabled" | "unconfigured" | "unauthenticated" | "disabled" | "error";
interface AssistantGateProps extends PropsWithChildren {
availability: AssistantAvailability;
@@ -79,5 +87,13 @@ export const AssistantGate = ({ availability, children }: AssistantGateProps) =>
return <EnabledAssistantRoot>{children}</EnabledAssistantRoot>;
}
if (availability === "disabled") {
return (
<DisabledAssistant description={t("unavailable.disabledByServer")} discoverable={false}>
{children}
</DisabledAssistant>
);
}
return <DisabledAssistant description={t(unavailableMessageKeys[availability])}>{children}</DisabledAssistant>;
};
@@ -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(" ") }}
@@ -35,11 +35,19 @@ import classes from "./board-switcher.module.css";
export const boardSwitcherHotkey = "shift+c";
interface BoardSwitcherProps {
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode;
export interface BoardSwitcherControls {
enabled: boolean;
open: () => void;
preload: () => void;
hotkey: string;
}
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
interface BoardSwitcherProps {
enabled?: boolean;
children: (controls: BoardSwitcherControls) => ReactNode;
}
export const BoardSwitcher = ({ enabled = true, children }: BoardSwitcherProps) => {
const t = useI18n("board.action.switcher");
const manageBoardsT = useI18n("management.page.board");
const currentBoard = useOptionalBoard();
@@ -56,7 +64,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
} = clientApi.board.getManageOverview.useQuery(
{ fullPreview: true },
{
enabled: isOpen,
enabled: enabled && isOpen,
},
);
@@ -76,16 +84,24 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
const modalColumnCount = Math.max(1, Math.min(switcherBoards.length, responsiveColumnCount));
const openSwitcher = useCallback(() => {
if (!enabled) return;
setIsOpen(true);
}, []);
}, [enabled]);
const closeSwitcher = useCallback(() => {
setIsOpen(false);
setSearch("");
setActiveIndex(0);
}, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
const preloadBoards = () => {
if (!enabled) return;
void utils.board.getManageOverview.prefetch({ fullPreview: true });
};
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
useHotkeys(enabled ? [[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]] : []);
useEffect(() => {
if (!enabled && isOpen) closeSwitcher();
}, [closeSwitcher, enabled, isOpen]);
useEffect(() => {
if (!isOpen || filteredBoards.length === 0) return;
@@ -170,10 +186,10 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
return (
<>
{children({ open: openSwitcher, preload: preloadBoards, hotkey: boardSwitcherHotkey })}
{children({ enabled, open: openSwitcher, preload: preloadBoards, hotkey: boardSwitcherHotkey })}
<Modal
opened={isOpen}
opened={enabled && isOpen}
onClose={closeSwitcher}
withCloseButton={false}
yOffset="15vh"
@@ -222,7 +222,15 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
if (
!session ||
!settings.featureControls.widgetContextMenuEnabled ||
!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 }), {
@@ -1 +1,2 @@
export const appShellHeaderHeight = 60;
export const appShellLogoHeight = appShellHeaderHeight - 8;
+20 -37
View File
@@ -1,14 +1,11 @@
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 } from "./logo/homarr-logo";
interface Props {
logo?: ReactNode;
@@ -16,34 +13,20 @@ interface Props {
hasNavigation?: boolean;
}
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
export const MainHeader = async ({ logo, actions, 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} />}
actions={actions}
hasNavigation={hasNavigation}
avatar={<CurrentUserAvatar size="md" />}
userId={session?.user.id ?? null}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
/>
);
};
@@ -0,0 +1,129 @@
.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);
direction: ltr;
}
.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;
}
.logo {
display: grid;
place-items: center;
width: auto;
height: calc(100% - 0.5rem);
max-width: min(12rem, 28vw);
flex: 0 0 auto;
overflow: hidden;
border-radius: var(--mantine-radius-md);
}
.logo :global(img.logo) {
display: block;
width: auto;
height: 100%;
max-width: 100%;
object-fit: contain;
}
.logo > * {
flex-wrap: nowrap;
}
.logo :global(.mantine-Title-root) {
display: none;
}
.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 :global([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);
inset-block-start: max(rem(10px), env(safe-area-inset-top));
inset-inline-end: 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%);
}
.floatingControls :global(.mantine-UnstyledButton-root) {
display: flex;
align-items: center;
justify-content: center;
}
:global([dir="rtl"]) .floatingControls {
inset-inline-end: max(rem(10px), env(safe-area-inset-left));
}
@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;
}
}
@@ -0,0 +1,323 @@
"use client";
import type { ReactNode } from "react";
import { useMemo } from "react";
import {
AppShellHeader,
Avatar,
Group,
Indicator,
Loader,
Tooltip,
UnstyledButton,
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 { Link } from "@homarr/ui";
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 { 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;
actions?: ReactNode;
hasNavigation: boolean;
avatar: ReactNode;
userId: string | null;
isAdmin: boolean;
isDockerEnabled: boolean;
}
type HeaderBoard = RouterOutputs["board"]["getAllBoards"][number];
export const ConfigurableHeader = ({
logo,
actions,
hasNavigation,
avatar,
userId,
isAdmin,
isDockerEnabled,
}: ConfigurableHeaderProps) => {
const { featureControls, headerPreferences } = useSettings();
const assistantEnabled = featureControls.assistantEnabled;
const boardSwitcherEnabled = featureControls.boardSwitcherEnabled;
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 enabled={boardSwitcherEnabled}>
{(boardSwitcher) => {
const renderItems = (items: HeaderItem[]) =>
items.map((item) => (
<HeaderItem
key={getHeaderItemKey(item)}
item={item}
logo={logo}
avatar={avatar}
userId={userId}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
assistantEnabled={assistantEnabled}
boardSwitcherEnabled={boardSwitcherEnabled}
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
searchDisplay={headerPreferences.searchDisplay}
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>
) : (
<Group className={classes.floatingControls} gap={4} wrap="nowrap">
{hasNavigation ? <ClientBurger /> : null}
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</Group>
)}
<LazySpotlight />
</>
);
}}
</BoardSwitcher>
);
};
interface HeaderItemProps {
item: HeaderItem;
logo: ReactNode;
avatar: ReactNode;
userId: string | null;
isAdmin: boolean;
isDockerEnabled: boolean;
assistantEnabled: boolean;
boardSwitcherEnabled: boolean;
board: HeaderBoard | undefined;
searchDisplay: "input" | "icon";
boardSwitcher: BoardSwitcherControls;
assistant: ReturnType<typeof useOptionalHomarrAssistant>;
openDockerModal: () => void;
toggleColorScheme: () => void;
label: (key: string) => string;
boardUnavailableLabel: string;
}
const HeaderItem = ({
item,
logo,
avatar,
userId,
isAdmin,
isDockerEnabled,
assistantEnabled,
boardSwitcherEnabled,
board,
searchDisplay,
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 (
<Tooltip label={label("logo")}>
<UnstyledButton component={Link} href="/" className={classes.logo} aria-label={label("logo")}>
{logo}
</UnstyledButton>
</Tooltip>
);
}
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") {
if (!boardSwitcherEnabled) return null;
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 (!assistantEnabled || !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>
);
};
@@ -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 ?? branding.logoImageUrl ?? 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,38 @@
"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();
return {
title: branding.appName,
image: {
src: branding.logoImageUrl ?? homarrLogoPath,
alt: `${branding.appName} logo`,
shouldUseNextImage: branding.logoImageUrl === null,
},
};
};
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} />;
};
@@ -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>
)}
</>
+105 -104
View File
@@ -18,12 +18,13 @@ import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
import { hotkeys, invariantTechnicalLabels } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
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 +34,7 @@ interface UserAvatarMenuProps {
children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean;
boardSwitcher: BoardSwitcherControls;
}
const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
@@ -41,9 +43,10 @@ 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 { branding } = useSettings();
const session = useSession();
const { logoutUrl } = useAuthContext();
@@ -59,111 +62,109 @@ 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>
{boardSwitcher.enabled ? (
<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>
) : null}
<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", { app: branding.appName })}
</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>
);
};
+4 -1
View File
@@ -1 +1,4 @@
export const createMetaTitle = (name: string) => `${name} • Homarr`;
export const createMetaTitle = (name: string, appName?: string) => {
if (appName) return `${name} • ${appName}`;
return name;
};
+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 } },
},
}),
);
+19 -9
View File
@@ -19,6 +19,7 @@ import {
items,
users,
} from "@homarr/db/schema";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
assistantProviderCanUseOpenRouterServerTools,
assistantProviderIds,
@@ -37,6 +38,13 @@ import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedPro
import { boardRouter, getHomeIdBoardAsync } from "./board";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const assistantEnabledProcedure = protectedProcedure.use(async ({ ctx, next }) => {
const featureControls = await getServerSettingByKeyAsync(ctx.db, "featureControls");
if (!featureControls.assistantEnabled) {
throw new TRPCError({ code: "NOT_FOUND", message: "Homarr Assistant is disabled." });
}
return next({ ctx });
});
const configurationId = "default";
const providerSchema = z.enum(assistantProviderIds);
@@ -453,6 +461,8 @@ const addFeedbackToMessageContent = (serializedContent: string, type: "positive"
export const assistantRouter = createTRPCRouter({
getAvailability: protectedProcedure.query(async ({ ctx }) => {
const featureControls = await getServerSettingByKeyAsync(ctx.db, "featureControls");
if (!featureControls.assistantEnabled) return { enabled: false };
if (isDemoMode) {
return { enabled: true };
}
@@ -465,11 +475,11 @@ export const assistantRouter = createTRPCRouter({
};
}),
getContextEntities: protectedProcedure.query(async ({ ctx }) => {
getContextEntities: assistantEnabledProcedure.query(async ({ ctx }) => {
return await getAssistantContextEntitiesAsync(ctx);
}),
getModelCapabilities: protectedProcedure
getModelCapabilities: assistantEnabledProcedure
.input(z.object({ modelId: z.string().trim().min(1).max(256).optional() }).optional())
.query(async ({ ctx, input }) => {
const configuration = await getConfigurationAsync(ctx.db);
@@ -486,7 +496,7 @@ export const assistantRouter = createTRPCRouter({
};
}),
getRuntimeOptions: protectedProcedure.query(async ({ ctx }) => {
getRuntimeOptions: assistantEnabledProcedure.query(async ({ ctx }) => {
const configuration = await getConfigurationAsync(ctx.db);
if (isDemoMode) {
return {
@@ -684,7 +694,7 @@ export const assistantRouter = createTRPCRouter({
});
}),
createThread: protectedProcedure
createThread: assistantEnabledProcedure
.input(z.object({ localId: z.string().max(128).optional() }).optional())
.mutation(async ({ ctx }) => {
const configuration = await getConfigurationAsync(ctx.db);
@@ -700,7 +710,7 @@ export const assistantRouter = createTRPCRouter({
return { id };
}),
updateThreadModel: protectedProcedure
updateThreadModel: assistantEnabledProcedure
.input(z.object({ threadId: z.string().max(64), modelId: z.string().trim().min(1).max(256) }))
.mutation(async ({ ctx, input }) => {
const [thread, configuration] = await Promise.all([
@@ -745,7 +755,7 @@ export const assistantRouter = createTRPCRouter({
};
}),
getGenerationTelemetry: protectedProcedure
getGenerationTelemetry: assistantEnabledProcedure
.input(
z.object({
threadId: z.string().max(64),
@@ -791,7 +801,7 @@ export const assistantRouter = createTRPCRouter({
};
}),
renameThread: protectedProcedure
renameThread: assistantEnabledProcedure
.input(z.object({ threadId: z.string().max(64), title: z.string().trim().min(1).max(120) }))
.mutation(async ({ ctx, input }) => {
await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
@@ -808,7 +818,7 @@ export const assistantRouter = createTRPCRouter({
await ctx.db.delete(assistantThreads).where(eq(assistantThreads.id, input.threadId));
}),
appendMessage: protectedProcedure
appendMessage: assistantEnabledProcedure
.input(
z.object({
threadId: z.string().max(64),
@@ -869,7 +879,7 @@ export const assistantRouter = createTRPCRouter({
.where(eq(assistantThreads.id, thread.id));
}),
submitFeedback: protectedProcedure
submitFeedback: assistantEnabledProcedure
.input(
z.object({
threadId: z.string().max(64),
+29 -3
View File
@@ -86,7 +86,9 @@ interface BoardItemPlacementRectangle {
const boardItemPlacementTails = new Map<string, Promise<void>>();
const maxManageOverviewPreviewRows = 12;
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
const manageOverviewInputSchema = z
.object({ fullPreview: z.boolean().default(false), userId: z.string().optional() })
.optional();
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
@@ -227,9 +229,14 @@ export const boardRouter = createTRPCRouter({
}));
}),
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
const userId = ctx.session?.user.id;
const sessionUserId = ctx.session?.user.id;
const userId = input?.userId ?? sessionUserId;
if (userId !== sessionUserId && !ctx.session?.user.permissions.includes("admin")) {
throw new TRPCError({ code: "FORBIDDEN", message: "You are not allowed to view boards for this user" });
}
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
const canViewAllBoards = getCanViewAllBoards(groupMemberships);
const dbBoards = await ctx.db.query.boards.findMany({
columns: {
@@ -279,7 +286,7 @@ export const boardRouter = createTRPCRouter({
orderBy: (section, { asc }) => [asc(section.xOffset), asc(section.id)],
},
},
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
where: getAccessibleBoardsWhere(canViewAllBoards, userId, boardIds),
});
const previewLayoutIds = dbBoards.flatMap((board) => board.layouts.map((layout) => layout.id));
@@ -2566,6 +2573,7 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin
group: {
with: {
boardPermissions: {},
permissions: {},
},
},
},
@@ -2589,6 +2597,24 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin
return { boardIds, currentUser, groupMemberships };
};
const getCanViewAllBoards = (
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
) => {
const permissions = new Set(
groupMemberships.flatMap((membership) => membership.group.permissions.map(({ permission }) => permission)),
);
return getPermissionsWithChildren([...permissions]).includes("board-view-all");
};
export const getAccessibleBoardIdsForUserAsync = async (db: Database, userId: string) => {
const { boardIds, groupMemberships } = await getBoardAccessContextAsync(db, userId);
const accessibleBoards = await db.query.boards.findMany({
columns: { id: true },
where: getAccessibleBoardsWhere(getCanViewAllBoards(groupMemberships), userId, boardIds),
});
return new Set(accessibleBoards.map(({ id }) => id));
};
const getBoardGroupPermissionWhere = (
groupMemberships: Awaited<ReturnType<typeof getBoardAccessContextAsync>>["groupMemberships"],
) =>
+47 -2
View File
@@ -10,7 +10,14 @@ import {
} from "@homarr/db/queries";
import { boards, serverSettings } from "@homarr/db/schema";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import {
authBrandingSchema,
brandingServerSettingsSchema,
defaultServerSettingsKeys,
featureControlsServerSettingsSchema,
parseBrandingSettings,
parseFeatureControls,
} from "@homarr/server-settings";
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../trpc";
@@ -22,6 +29,15 @@ const boardServerSettingsSchema = z.object({
}) satisfies z.ZodType<ServerSettings["board"]>;
const boardServerSettingsUpdateSchema = boardServerSettingsSchema.partial();
const brandingServerSettingsUpdateSchema = brandingServerSettingsSchema.partial().extend({
authBranding: authBrandingSchema.partial().optional(),
});
const legacyAuthBrandingUpdateSchema = z.object({
showCustomAppNameOnLogin: z.boolean().optional(),
showCustomLogoOnLogin: z.boolean().optional(),
showCustomGreetingOnLogin: z.boolean().optional(),
});
const featureControlsServerSettingsUpdateSchema = featureControlsServerSettingsSchema.partial();
export const serverSettingsRouter = createTRPCRouter({
getCulture: publicProcedure.query(async ({ ctx }) => {
@@ -30,6 +46,10 @@ export const serverSettingsRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await getServerSettingsAsync(ctx.db);
}),
getBranding: publicProcedure.query(async ({ ctx }) => {
const branding = await getServerSettingByKeyAsync(ctx.db, "branding");
return parseBrandingSettings(branding);
}),
getBoardSettings: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
@@ -100,10 +120,35 @@ export const serverSettingsRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (input.settingsKey === "branding") {
const current = await getServerSettingByKeyAsync(ctx.db, "branding");
const parsedInput = brandingServerSettingsUpdateSchema.parse(input.value);
const legacyInput = legacyAuthBrandingUpdateSchema.parse(input.value);
const authBranding = { ...current.authBranding };
authBranding.showAppName = legacyInput.showCustomAppNameOnLogin ?? authBranding.showAppName;
authBranding.showLogo = legacyInput.showCustomLogoOnLogin ?? authBranding.showLogo;
authBranding.showGreeting = legacyInput.showCustomGreetingOnLogin ?? authBranding.showGreeting;
Object.assign(authBranding, parsedInput.authBranding);
const value = brandingServerSettingsSchema.parse({
...parseBrandingSettings(current),
...parsedInput,
authBranding,
});
await updateServerSettingByKeyAsync(ctx.db, "branding", value);
return;
}
if (input.settingsKey === "featureControls") {
const current = await getServerSettingByKeyAsync(ctx.db, "featureControls");
const parsedInput = featureControlsServerSettingsUpdateSchema.parse(input.value);
const value = parseFeatureControls({ ...current, ...parsedInput });
await updateServerSettingByKeyAsync(ctx.db, "featureControls", value);
return;
}
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
...current,
...input.value,
} as ServerSettings[keyof ServerSettings]);
} as ServerSettings[typeof input.settingsKey]);
}),
});
+63 -1
View File
@@ -15,8 +15,11 @@ import { selectUserSchema } from "@homarr/db/validationSchemas";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
import { byIdSchema } from "@homarr/validation/common";
import type { userBaseCreateSchema } from "@homarr/validation/user";
import type { HeaderPreferences, userBaseCreateSchema } from "@homarr/validation/user";
import {
getHeaderItems,
headerPreferencesMutationSchema,
parseHeaderPreferences,
userChangeColorSchemeSchema,
userChangeHomeBoardsSchema,
userChangePasswordApiSchema,
@@ -30,6 +33,7 @@ import {
userPingIconsEnabledSchema,
userRegistrationApiSchema,
} from "@homarr/validation/user";
import { serializeHeaderPreferences } from "@homarr/validation/header-preferences";
import { convertIntersectionToZodObject } from "../schema-merger";
import {
@@ -40,6 +44,7 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
import { getAccessibleBoardIdsForUserAsync } from "./board";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfCredentialsDisabled } from "./invite/checks";
import { changeSearchPreferencesAsync, changeSearchPreferencesInputSchema } from "./user/change-search-preferences";
@@ -368,6 +373,7 @@ export const userRouter = createTRPCRouter({
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
headerPreferences: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
@@ -405,6 +411,7 @@ export const userRouter = createTRPCRouter({
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
headerPreferences: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
@@ -719,6 +726,58 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.id));
}),
changeHeaderPreferences: protectedProcedure
.input(z.object({ id: z.string(), headerPreferences: headerPreferencesMutationSchema }))
.output(z.void())
.meta({
openapi: {
method: "PATCH",
path: "/api/users/header-preferences",
tags: ["users"],
protect: true,
},
})
.mutation(async ({ input, ctx }) => {
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const targetUser = await ctx.db.query.users.findFirst({
columns: { headerPreferences: true },
where: eq(users.id, input.id),
});
if (!targetUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const submittedBoardIds = getBoardShortcutIds(input.headerPreferences);
if (submittedBoardIds.length > 0) {
const existingBoardIds = new Set(getBoardShortcutIds(parseHeaderPreferences(targetUser.headerPreferences)));
const accessibleBoardIds = await getAccessibleBoardIdsForUserAsync(ctx.db, input.id);
const hasUnavailableShortcut = submittedBoardIds.some(
(boardId) => !accessibleBoardIds.has(boardId) && !existingBoardIds.has(boardId),
);
if (hasUnavailableShortcut) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "One or more board shortcuts are unavailable to this user",
});
}
}
await ctx.db
.update(users)
.set({
headerPreferences: serializeHeaderPreferences(input.headerPreferences),
})
.where(eq(users.id, input.id));
}),
changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
.output(z.void())
@@ -829,6 +888,9 @@ export const userRouter = createTRPCRouter({
}),
});
const getBoardShortcutIds = (preferences: HeaderPreferences) =>
getHeaderItems(preferences.zones).flatMap((item) => (item.type === "board" ? [item.boardId] : []));
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
const hashedPassword = await hashPasswordAsync(input.password);
+1
View File
@@ -13,6 +13,7 @@ export const getRscUserSettingsAsync = cache(async (userId: string) =>
firstDayOfWeek: true,
pingIconsEnabled: true,
enableRightClickOnWidgets: true,
headerPreferences: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `header_preferences` text DEFAULT ('{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"}]}}') NOT NULL;
@@ -0,0 +1,2 @@
INSERT IGNORE INTO `serverSetting` (`setting_key`, `value`)
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}');
@@ -0,0 +1,2 @@
INSERT IGNORE INTO `serverSetting` (`setting_key`, `value`)
VALUES ('featureControls', '{"json":{"assistantEnabled":true,"boardSwitcherEnabled":true,"widgetContextMenuEnabled":true}}');
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -337,6 +337,27 @@
"when": 1786217088950,
"tag": "0047_add_assistant_tables",
"breakpoints": true
},
{
"idx": 48,
"version": "5",
"when": 1787496042445,
"tag": "0048_add_user_header_preferences",
"breakpoints": true
},
{
"idx": 49,
"version": "5",
"when": 1787496053718,
"tag": "0049_add_server_branding_settings",
"breakpoints": true
},
{
"idx": 50,
"version": "5",
"when": 1787496060001,
"tag": "0050_add_feature_controls_server_setting",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "header_preferences" text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"}]}}' NOT NULL;
@@ -0,0 +1,3 @@
INSERT INTO "serverSetting" ("setting_key", "value")
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}')
ON CONFLICT ("setting_key") DO NOTHING;
@@ -0,0 +1,3 @@
INSERT INTO "serverSetting" ("setting_key", "value")
VALUES ('featureControls', '{"json":{"assistantEnabled":true,"boardSwitcherEnabled":true,"widgetContextMenuEnabled":true}}')
ON CONFLICT ("setting_key") DO NOTHING;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -99,6 +99,27 @@
"when": 1786217088954,
"tag": "0013_add_assistant_tables",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1787496042486,
"tag": "0014_add_user_header_preferences",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1787496053659,
"tag": "0015_add_server_branding_settings",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1787496060002,
"tag": "0016_add_feature_controls_server_setting",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `header_preferences` text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"}]}}' NOT NULL;
@@ -0,0 +1,3 @@
INSERT INTO `serverSetting` (`setting_key`, `value`)
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}')
ON CONFLICT (`setting_key`) DO NOTHING;
@@ -0,0 +1,3 @@
INSERT INTO `serverSetting` (`setting_key`, `value`)
VALUES ('featureControls', '{"json":{"assistantEnabled":true,"boardSwitcherEnabled":true,"widgetContextMenuEnabled":true}}')
ON CONFLICT (`setting_key`) DO NOTHING;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -323,6 +323,27 @@
"when": 1786217088950,
"tag": "0045_add_assistant_tables",
"breakpoints": true
},
{
"idx": 46,
"version": "6",
"when": 1787496042447,
"tag": "0046_add_user_header_preferences",
"breakpoints": true
},
{
"idx": 47,
"version": "6",
"when": 1787496053654,
"tag": "0047_add_server_branding_settings",
"breakpoints": true
},
{
"idx": 48,
"version": "6",
"when": 1787496060000,
"tag": "0048_add_feature_controls_server_setting",
"breakpoints": true
}
]
}
+30 -4
View File
@@ -1,7 +1,12 @@
import SuperJSON from "superjson";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import {
defaultServerSettings,
defaultServerSettingsKeys,
parseBrandingSettings,
parseFeatureControls,
} from "@homarr/server-settings";
import type { Database } from "..";
import { eq } from "..";
@@ -18,10 +23,20 @@ export const getServerSettingsAsync = async (db: Database) => {
return acc;
}
const parsedSetting = SuperJSON.parse<Record<string, unknown>>(setting.value);
if (settingKey === "branding") {
acc[settingKey] = parseBrandingSettings(parsedSetting) as never;
return acc;
}
if (settingKey === "featureControls") {
acc[settingKey] = parseFeatureControls(parsedSetting) as never;
return acc;
}
acc[settingKey] = {
...defaultServerSettings[settingKey],
...SuperJSON.parse(setting.value),
};
...parsedSetting,
} as never;
return acc;
}, {} as ServerSettings);
};
@@ -35,7 +50,18 @@ export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettin
return defaultServerSettings[key];
}
return SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
const parsedSetting = SuperJSON.parse<ServerSettings[TKey]>(dbSettings.value);
if (key === "branding") {
return parseBrandingSettings(parsedSetting) as ServerSettings[TKey];
}
if (key === "featureControls") {
return parseFeatureControls(parsedSetting) as ServerSettings[TKey];
}
return {
...defaultServerSettings[key],
...parsedSetting,
} as ServerSettings[TKey];
};
export const updateServerSettingByKeyAsync = async <TKey extends keyof ServerSettings>(
+2
View File
@@ -43,6 +43,7 @@ import type {
WidgetKind,
} from "@homarr/definitions";
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
import { defaultHeaderPreferencesSerialized } from "@homarr/validation/header-preferences";
const customBlob = customType<{ data: Buffer }>({
dataType() {
@@ -85,6 +86,7 @@ export const users = mysqlTable("user", {
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
headerPreferences: text().default(defaultHeaderPreferencesSerialized).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
});
+2
View File
@@ -41,6 +41,7 @@ import type {
WidgetKind,
} from "@homarr/definitions";
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
import { defaultHeaderPreferencesSerialized } from "@homarr/validation/header-preferences";
const customBlob = customType<{ data: Buffer }>({
dataType() {
@@ -83,6 +84,7 @@ export const users = pgTable("user", {
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
enableRightClickOnWidgets: boolean().default(true).notNull(),
headerPreferences: text().default(defaultHeaderPreferencesSerialized).notNull(),
completedManageTour: boolean().default(false).notNull(),
completedBoardTour: boolean().default(false).notNull(),
});
+2
View File
@@ -29,6 +29,7 @@ import type {
WidgetKind,
} from "@homarr/definitions";
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
import { defaultHeaderPreferencesSerialized } from "@homarr/validation/header-preferences";
export * from "@homarr/core/infrastructure/certificates/hostnames/db/sqlite";
@@ -65,6 +66,7 @@ export const users = sqliteTable("user", {
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
enableRightClickOnWidgets: int({ mode: "boolean" }).default(true).notNull(),
headerPreferences: text().default(defaultHeaderPreferencesSerialized).notNull(),
completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
});
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import type { CSSProperties, ReactNode } from "react";
import { Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { OnboardingBackdrop } from "./onboarding-backdrop";
@@ -11,17 +11,68 @@ interface OnboardingAuthShellProps {
icon: ReactNode;
children: ReactNode;
footer?: ReactNode;
appName?: string;
showAppName?: boolean;
showAppLogo?: boolean;
primaryColor?: string;
secondaryColor?: string;
logoImageUrl?: string;
backgroundImageUrl?: string;
backgroundOverlay?: number;
radius?: "xs" | "sm" | "md" | "lg" | "xl";
}
export const OnboardingAuthShell = ({ title, description, icon, children, footer }: OnboardingAuthShellProps) => (
<main className={classes.page}>
export const OnboardingAuthShell = ({
title,
description,
icon,
children,
footer,
appName,
showAppName = false,
showAppLogo = false,
primaryColor,
secondaryColor,
logoImageUrl,
backgroundImageUrl,
backgroundOverlay = 0.55,
radius = "lg",
}: OnboardingAuthShellProps) => (
<main
className={classes.page}
data-background-image={backgroundImageUrl ? true : undefined}
style={
{
"--studio-glow-color": primaryColor,
"--studio-secondary-glow-color": secondaryColor,
"--auth-background-overlay": backgroundOverlay,
} as CSSProperties
}
>
{backgroundImageUrl ? (
<div
aria-hidden
className={classes.authBackground}
style={{ backgroundImage: `url(${JSON.stringify(backgroundImageUrl)})` }}
/>
) : null}
<OnboardingBackdrop />
<div className={`${classes.shell} ${classes.authShell}`}>
<Stack className={classes.authLayout} gap="xl" align="center" justify="center">
<div className={classes.authBrand}>
<OnboardingWordmark large />
</div>
<Paper className={`${classes.studio} ${classes.authCard}`} radius="lg" p={{ base: "lg", sm: "xl" }}>
{showAppName || showAppLogo ? (
<div className={classes.authBrand}>
<OnboardingWordmark
large
appName={appName}
showAppName={showAppName}
showAppLogo={showAppLogo}
primaryColor={primaryColor}
secondaryColor={secondaryColor}
logoImageUrl={logoImageUrl}
/>
</div>
) : null}
<Paper className={`${classes.studio} ${classes.authCard}`} radius={radius} p={{ base: "lg", sm: "xl" }}>
<Stack gap="xl">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon size="xl" radius="lg" variant="light">
@@ -83,6 +83,33 @@
max-width: min(92vw, 42rem);
}
.customBrand {
display: flex;
align-items: center;
justify-content: center;
gap: var(--mantine-spacing-md);
max-width: min(92vw, 42rem);
color: var(--mantine-color-text);
font-size: clamp(2rem, 6vw, 4rem);
font-weight: 750;
line-height: 1;
letter-spacing: -0.04em;
}
.customBrand[data-large="true"] {
font-size: clamp(2.5rem, 8vw, 5.5rem);
}
.customBrandImage {
width: auto;
height: clamp(4rem, 11vw, 7rem);
object-fit: contain;
}
.customBrand[data-large="true"] .customBrandImage {
height: clamp(5rem, 16vw, 9rem);
}
.celebratingWordmark {
animation: wordmarkJump 360ms ease-out;
}
@@ -135,6 +162,25 @@
padding-block: clamp(1rem, 4vw, 3rem) calc(clamp(1rem, 4vw, 3rem) + env(safe-area-inset-bottom));
}
.authBackground {
position: fixed;
z-index: 0;
inset: 0;
background-position: center;
background-size: cover;
}
.authBackground::after {
position: absolute;
inset: 0;
content: "";
background: rgb(0 0 0 / var(--auth-background-overlay, 0.55));
}
.page[data-background-image] .marqueeBackdrop {
opacity: 0.14;
}
.authLayout {
width: min(100%, 36rem);
margin: auto;
@@ -10,12 +10,24 @@ import classes from "./onboarding-studio.module.css";
import { useOnboardingSounds } from "./use-onboarding-sounds";
interface OnboardingWordmarkProps {
appName?: string;
logoImageUrl?: string;
showAppName?: boolean;
showAppLogo?: boolean;
primaryColor?: string;
secondaryColor?: string;
large?: boolean;
}
export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false }: OnboardingWordmarkProps) => {
export const OnboardingWordmark = ({
appName,
logoImageUrl,
showAppName,
showAppLogo,
primaryColor,
secondaryColor,
large = false,
}: OnboardingWordmarkProps) => {
const [celebrating, setCelebrating] = useState(false);
const colorScheme = useComputedColorScheme("light");
const sounds = useOnboardingSounds();
@@ -29,7 +41,7 @@ export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false
sounds.hover();
const logo = event.currentTarget.getBoundingClientRect();
const originY = (logo.top + logo.height / 2) / window.innerHeight;
const colors = primaryColor && secondaryColor ? [primaryColor, secondaryColor] : undefined;
const confettiColors = primaryColor && secondaryColor ? [primaryColor, secondaryColor] : undefined;
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) setCelebrating(true);
for (const { angle, originX } of [
@@ -45,18 +57,41 @@ export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false
ticks: 90,
scalar: 0.8,
origin: { x: originX / window.innerWidth, y: originY },
colors,
colors: confettiColors,
disableForReducedMotion: true,
});
}
};
const className = `${classes.wordmark} ${classes.onboardingWordmark} ${large ? classes.welcomeWordmark : ""} ${celebrating ? classes.celebratingWordmark : ""}`;
const hasVisibilityControls = showAppName !== undefined || showAppLogo !== undefined;
const resolvedAppName = appName?.trim() || "Homarr";
const shouldRenderDefaultWordmark = showAppLogo && showAppName && !logoImageUrl && resolvedAppName === "Homarr";
if (hasVisibilityControls && !shouldRenderDefaultWordmark) {
const visibleLogoImageUrl = showAppLogo ? (logoImageUrl ?? "/logo/logo.png") : undefined;
if (!visibleLogoImageUrl && !showAppName) return null;
return (
<div className={classes.customBrand} data-large={large || undefined} aria-label={resolvedAppName}>
{visibleLogoImageUrl ? (
<img
className={classes.customBrandImage}
src={visibleLogoImageUrl}
alt={showAppName ? "" : resolvedAppName}
/>
) : null}
{showAppName ? <span>{resolvedAppName}</span> : null}
</div>
);
}
return (
<HomarrWordmarkLight
role="img"
aria-label="Homarr"
aria-label={resolvedAppName}
style={colors}
className={`${classes.wordmark} ${classes.onboardingWordmark} ${large ? classes.welcomeWordmark : ""} ${celebrating ? classes.celebratingWordmark : ""}`}
className={className}
onMouseEnter={celebrate}
onAnimationEnd={() => setCelebrating(false)}
/>
+24 -33
View File
@@ -34,7 +34,6 @@ import {
UnstyledButton,
useMantineColorScheme,
} from "@mantine/core";
import type { MantineSize } from "@mantine/core";
import {
IconAdjustments,
IconAlertCircle,
@@ -91,7 +90,16 @@ import {
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
import type { SupportedLanguage } from "@homarr/translation";
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import { BoardColorInput, ColorSchemeCombobox, IntegrationAvatar, LanguageCombobox, Link } from "@homarr/ui";
import type { CornerStyle } from "@homarr/ui";
import {
BoardColorInput,
ColorSchemeCombobox,
CornerStylePicker,
cornerStyleValues,
IntegrationAvatar,
LanguageCombobox,
Link,
} from "@homarr/ui";
import { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid";
import type { OnboardingStudioProps } from "./types";
@@ -146,7 +154,6 @@ const sectionDefinitions = [
{ id: "review", icon: IconCheck },
] as const;
const radiusValues = ["xs", "sm", "md", "lg", "xl"] as const;
const initialPrimaryColor = "#fa5252";
const initialSecondaryColor = "#fd7e14";
const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = [];
@@ -176,7 +183,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
const [boardName, setBoardName] = useState(initialBoard?.name ?? "dashboard");
const [primaryColor, setPrimaryColor] = useState(initialPrimaryColor);
const [secondaryColor, setSecondaryColor] = useState(initialSecondaryColor);
const [itemRadius, setItemRadius] = useState<MantineSize>("lg");
const [itemRadius, setItemRadius] = useState<CornerStyle>("lg");
const [columnCount, setColumnCount] = useState(10);
const [leftSidebar, setLeftSidebar] = useState(false);
const [rightSidebar, setRightSidebar] = useState(false);
@@ -797,8 +804,8 @@ interface StudioSectionProps {
setPrimaryColor: (value: string) => void;
secondaryColor: string;
setSecondaryColor: (value: string) => void;
itemRadius: MantineSize;
setItemRadius: (value: MantineSize) => void;
itemRadius: CornerStyle;
setItemRadius: (value: CornerStyle) => void;
columnCount: number;
setColumnCount: (value: number) => void;
leftSidebar: boolean;
@@ -1452,32 +1459,16 @@ const BoardBuilder = (props: StudioSectionProps) => {
</Button>
) : null}
</Stack>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("radius")}
</Text>
<OnboardingFloatingControl
ariaLabel={t("radius")}
value={props.itemRadius}
onChange={props.setItemRadius}
options={radiusValues.map((value) => ({ value, label: value.toUpperCase() }))}
/>
<Group gap="xs" aria-hidden>
{radiusValues.map((radius) => (
<div
key={radius}
style={{
flex: 1,
height: 34,
borderRadius: `var(--mantine-radius-${radius})`,
background: "var(--mantine-color-default)",
border: "1px solid var(--mantine-color-default-border)",
opacity: props.itemRadius === radius ? 1 : 0.55,
}}
/>
))}
</Group>
</Stack>
<CornerStylePicker
label={t("radius")}
value={props.itemRadius}
labels={
Object.fromEntries(
cornerStyleValues.map((cornerStyle) => [cornerStyle, t(`radiusOptions.${cornerStyle}` as never)]),
) as Record<CornerStyle, string>
}
onChange={props.setItemRadius}
/>
</Stack>
<Stack gap="lg">
@@ -1543,7 +1534,7 @@ const BoardPreview = ({
appCount,
}: {
ariaLabel: string;
itemRadius: MantineSize;
itemRadius: CornerStyle;
baseColumnCount: number;
leftSidebar: boolean;
rightSidebar: boolean;
+2 -1
View File
@@ -22,7 +22,8 @@
},
"dependencies": {
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0"
"@homarr/translation": "workspace:^0.1.0",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
+121
View File
@@ -1,5 +1,6 @@
import type { ColorScheme } from "@homarr/definitions";
import type { SupportedLanguage } from "@homarr/translation";
import { z } from "zod/v4";
export const defaultServerSettingsKeys = [
"analytics",
@@ -7,12 +8,130 @@ export const defaultServerSettingsKeys = [
"board",
"user",
"appearance",
"branding",
"featureControls",
"culture",
"search",
] as const;
export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[number], Record<string, unknown>>;
export const brandingRadiusOptions = ["xs", "sm", "md", "lg", "xl"] as const;
export type BrandingRadius = (typeof brandingRadiusOptions)[number];
export const authBrandingSchema = z.object({
showAppName: z.boolean(),
showLogo: z.boolean(),
showGreeting: z.boolean(),
});
export const featureControlsServerSettingsSchema = z.object({
assistantEnabled: z.boolean(),
boardSwitcherEnabled: z.boolean(),
widgetContextMenuEnabled: z.boolean(),
});
export type FeatureControlsSettings = z.infer<typeof featureControlsServerSettingsSchema>;
const brandingImageUrlSchema = z
.string()
.trim()
.max(2_048)
.refine((value) => {
if (value.startsWith("/") && !value.startsWith("//")) return true;
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}, "Image must be an HTTP(S) URL or an app-relative path")
.nullable();
export const brandingServerSettingsSchema = z.object({
appName: z.string().trim().min(1).max(80),
greeting: z.string().trim().max(160),
logoImageUrl: brandingImageUrlSchema,
faviconImageUrl: brandingImageUrlSchema,
primaryColor: z.string().regex(/^#[\da-f]{6}$/iu, "Primary color must be a 6-digit hex color"),
secondaryColor: z.string().regex(/^#[\da-f]{6}$/iu, "Secondary color must be a 6-digit hex color"),
lockPrimaryColor: z.boolean(),
signInBackgroundImageUrl: brandingImageUrlSchema,
signInBackgroundOverlay: z.number().min(0).max(0.9),
authBranding: authBrandingSchema,
defaultRadius: z.enum(brandingRadiusOptions),
});
export type BrandingSettings = z.infer<typeof brandingServerSettingsSchema>;
export const defaultBrandingSettings: BrandingSettings = {
appName: "Homarr",
greeting: "",
logoImageUrl: null,
faviconImageUrl: null,
primaryColor: "#fa5252",
secondaryColor: "#fd7e14",
lockPrimaryColor: false,
signInBackgroundImageUrl: null,
signInBackgroundOverlay: 0.55,
authBranding: {
showAppName: true,
showLogo: true,
showGreeting: true,
},
defaultRadius: "md",
};
export const defaultFeatureControls: FeatureControlsSettings = {
assistantEnabled: true,
boardSwitcherEnabled: true,
widgetContextMenuEnabled: true,
};
const legacyLoginBrandingSchema = z.object({
showCustomAppNameOnLogin: z.boolean().optional(),
showCustomLogoOnLogin: z.boolean().optional(),
showCustomGreetingOnLogin: z.boolean().optional(),
showCustomAppNameOnInvite: z.boolean().optional(),
showCustomLogoOnInvite: z.boolean().optional(),
showCustomGreetingOnInvite: z.boolean().optional(),
});
export const parseBrandingSettings = (value: unknown): BrandingSettings => {
const partialValue = z.record(z.string(), z.unknown()).safeParse(value);
if (!partialValue.success) return defaultBrandingSettings;
const legacyBranding = legacyLoginBrandingSchema.safeParse(partialValue.data);
const partialAuthBranding = authBrandingSchema.partial().safeParse(partialValue.data.authBranding);
const authBranding = { ...defaultBrandingSettings.authBranding };
if (legacyBranding.success) {
const legacy = legacyBranding.data;
authBranding.showAppName = legacy.showCustomAppNameOnLogin ?? authBranding.showAppName;
authBranding.showLogo = legacy.showCustomLogoOnLogin ?? authBranding.showLogo;
authBranding.showGreeting = legacy.showCustomGreetingOnLogin ?? authBranding.showGreeting;
}
if (partialAuthBranding.success) Object.assign(authBranding, partialAuthBranding.data);
const result = brandingServerSettingsSchema.safeParse({
...defaultBrandingSettings,
...partialValue.data,
authBranding,
});
if (!result.success) return defaultBrandingSettings;
return result.data;
};
export const parseFeatureControls = (value: unknown) => {
const partialValue = z.record(z.string(), z.unknown()).safeParse(value);
if (!partialValue.success) return defaultFeatureControls;
const result = featureControlsServerSettingsSchema.safeParse({ ...defaultFeatureControls, ...partialValue.data });
if (!result.success) return defaultFeatureControls;
return result.data;
};
export const defaultServerSettings = {
analytics: {
enableGeneral: true,
@@ -36,6 +155,8 @@ export const defaultServerSettings = {
appearance: {
defaultColorScheme: "auto" as ColorScheme,
},
branding: defaultBrandingSettings,
featureControls: defaultFeatureControls,
culture: {
defaultLocale: "en" as SupportedLanguage,
},
+1
View File
@@ -24,6 +24,7 @@
"dependencies": {
"@homarr/db": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/dates": "catalog:",
"next": "catalog:",
"react": "catalog:",
+31 -13
View File
@@ -1,19 +1,31 @@
import type { User } from "@homarr/db/schema";
import type { ServerSettings } from "@homarr/server-settings";
import { parseBrandingSettings } from "@homarr/server-settings";
import type { HeaderPreferences } from "@homarr/validation/user";
import { parseHeaderPreferences } from "@homarr/validation/user";
export type SettingsContextProps = Pick<
User,
| "firstDayOfWeek"
| "defaultSearchEngineId"
| "homeBoardId"
| "mobileHomeBoardId"
| "openSearchInNewTab"
| "ddgBangs"
| "pingIconsEnabled"
| "enableRightClickOnWidgets"
> &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus"> &
Pick<ServerSettings["user"], "enableGravatar">;
export type SettingsContextProps = Omit<
Pick<
User,
| "firstDayOfWeek"
| "defaultSearchEngineId"
| "homeBoardId"
| "mobileHomeBoardId"
| "openSearchInNewTab"
| "ddgBangs"
| "pingIconsEnabled"
| "enableRightClickOnWidgets"
| "headerPreferences"
>,
"headerPreferences"
> & { headerPreferences: HeaderPreferences } & Pick<
ServerSettings["board"],
"enableStatusByDefault" | "forceDisableStatus"
> &
Pick<ServerSettings["user"], "enableGravatar"> & {
branding: ServerSettings["branding"];
featureControls: ServerSettings["featureControls"];
};
export interface PublicServerSettings {
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
@@ -22,6 +34,8 @@ export interface PublicServerSettings {
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
>;
user: Pick<ServerSettings["user"], "enableGravatar">;
branding: ServerSettings["branding"];
featureControls: ServerSettings["featureControls"];
}
export type UserSettings = Pick<
@@ -34,6 +48,7 @@ export type UserSettings = Pick<
| "ddgBangs"
| "pingIconsEnabled"
| "enableRightClickOnWidgets"
| "headerPreferences"
>;
export const createSettings = ({
@@ -51,7 +66,10 @@ export const createSettings = ({
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: user?.pingIconsEnabled ?? false,
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
headerPreferences: parseHeaderPreferences(user?.headerPreferences),
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
enableGravatar: serverSettings.user.enableGravatar,
branding: parseBrandingSettings(serverSettings.branding),
featureControls: serverSettings.featureControls,
});
+156 -2
View File
@@ -142,6 +142,13 @@
"secondaryColor": "Secondary color",
"resetColors": "Reset colors",
"radius": "Widget corner radius",
"radiusOptions": {
"xs": "Tight",
"sm": "Subtle",
"md": "Balanced",
"lg": "Rounded",
"xl": "Soft"
},
"columns": "Board columns",
"columnsDescription": "Columns define the horizontal grid used to size and position items. 12 is plenty for most people.",
"columnCount": "{count, plural, one {# column} other {# columns}}",
@@ -242,7 +249,9 @@
},
"invite": {
"title": "Join Homarr",
"titleWithAppName": "Join {appName}",
"subtitle": "Welcome to Homarr! Please create your account",
"subtitleWithAppName": "Welcome to {appName}! Please create your account",
"description": "You were invited by {username}"
}
},
@@ -1846,13 +1855,14 @@
},
"userAvatar": {
"menu": {
"open": "Open account menu",
"management": "Management",
"preferences": "Your preferences",
"logout": "Logout",
"login": "Login",
"homeBoard": "Your home board",
"updateAvailable": "{countUpdates} updates available: {tag}",
"assistant": "Homarr Assistant",
"assistant": "{app} Assistant",
"assistantThinking": "Thinking",
"modifier": "Mod"
}
@@ -5335,7 +5345,8 @@
}
},
"primaryColor": {
"label": "Primary color"
"label": "Primary color",
"locked": "Your administrator keeps the instance brand color on every board."
},
"secondaryColor": {
"label": "Secondary color"
@@ -5675,6 +5686,59 @@
"setting": {
"general": {
"title": "General",
"header": {
"title": "Header studio",
"badge": "Personal",
"description": "Drag each control across the fixed Left, Center, and Right dividers. Click a control to configure it.",
"device": {
"desktop": "Desktop",
"mobile": "Mobile"
},
"zones": {
"left": "Left",
"center": "Center",
"right": "Right"
},
"action": {
"hide": "Hide the header",
"show": "Show the header",
"configure": "Move or configure {item}",
"remove": "Remove {item}",
"dropAtEnd": "Drop at the end of the {zone} zone"
},
"preview": {
"board": "Dashboard preview for {name}",
"empty": "No dashboard layout is available"
},
"available": {
"title": "Available controls",
"description": "Add a control, then drag its full button into position above.",
"accountAlways": "Account access always remains available",
"allAdded": "Every built-in control is already in your header.",
"add": "Add {item}",
"boardShortcut": "Board shortcut",
"chooseBoard": "Choose a board"
},
"configuration": {
"description": "This control stays the same size as it does in the live header.",
"searchDisplay": {
"input": "Search bar",
"icon": "Icon only"
}
},
"items": {
"logo": "Logo",
"search": "Search",
"home": "Home board",
"boardSwitcher": "Boards",
"assistant": "Assistant",
"docker": "Docker",
"settings": "Your settings",
"themeToggle": "Toggle theme",
"user": "Account",
"boardUnavailable": "Board unavailable"
}
},
"item": {
"language": "Language & Region",
"board": {
@@ -5873,6 +5937,95 @@
"label": "Default color scheme"
}
},
"branding": {
"title": "Instance branding",
"groups": {
"identity": "Identity",
"appearance": "Appearance",
"authentication": "Authentication"
},
"identity": {
"description": "The application logo is the larger mark used inside the app, on sign-in pages, and for installed apps. The browser icon (favicon) is the small square used in tabs and bookmarks; when empty, it falls back to the application logo."
},
"appName": {
"label": "Application name"
},
"greeting": {
"label": "Custom greeting"
},
"logo": {
"label": "Application logo",
"placeholder": "/logo/logo.png"
},
"favicon": {
"label": "Browser icon (favicon)",
"placeholder": "Uses application logo"
},
"primaryColor": {
"label": "Primary color"
},
"secondaryColor": {
"label": "Secondary accent"
},
"lockPrimaryColor": {
"label": "Keep this color on every board",
"description": "Prevents board colors from replacing the application accent."
},
"signInBackground": {
"label": "Authentication background",
"placeholder": "https://example.com/background.jpg"
},
"signInOverlay": {
"label": "Background overlay"
},
"defaultRadius": {
"label": "Corner style",
"options": {
"xs": "Tight",
"sm": "Subtle",
"md": "Balanced",
"lg": "Rounded",
"xl": "Soft"
}
},
"authVisibility": {
"description": "Choose what appears on every authentication page.",
"appName": "Application name",
"logo": "Application logo",
"greeting": "Greeting"
},
"summary": {
"title": "Current branding",
"cornerStyle": "Corner style",
"palette": "Palette",
"backgroundConfigured": "Custom background · {overlay}% overlay",
"backgroundDefault": "Default background",
"greetingConfigured": "Custom greeting",
"greetingDefault": "Default greeting",
"boardColorLocked": "Board color locked",
"boardColorFlexible": "Board colors allowed",
"appName": "Name",
"logo": "Logo",
"greeting": "Greeting",
"on": "On",
"off": "Off"
}
},
"featureControls": {
"title": "Feature controls",
"assistant": {
"label": "Assistant",
"description": "Show and allow the instance Assistant."
},
"boardSwitcher": {
"label": "Board switcher",
"description": "Allow board switching from the header, account menu, and keyboard shortcut."
},
"widgetContextMenu": {
"label": "Widget context menu",
"description": "Allow widget actions from the context menu."
}
},
"culture": {
"title": "Culture",
"defaultLocale": {
@@ -7472,6 +7625,7 @@
},
"unavailable": {
"checking": "Checking whether the assistant is available…",
"disabledByServer": "An administrator has disabled Homarr Assistant for this server.",
"error": "The assistant's availability could not be checked. Try again.",
"notConfigured": "An administrator must configure and enable the assistant first.",
"signIn": "Sign in to use Homarr Assistant."
@@ -0,0 +1,66 @@
"use client";
import { Box, SimpleGrid, Stack, Text } from "@mantine/core";
import { SelectableCard } from "./selectable-card";
export const cornerStyleValues = ["xs", "sm", "md", "lg", "xl"] as const;
export type CornerStyle = (typeof cornerStyleValues)[number];
interface CornerStylePickerProps {
label: string;
description?: string;
value: CornerStyle;
labels: Record<CornerStyle, string>;
onChange: (value: CornerStyle) => void;
compact?: boolean;
}
export const CornerStylePicker = ({
label,
description,
value,
labels,
onChange,
compact = false,
}: CornerStylePickerProps) => {
const previewHeight = compact ? 24 : 34;
return (
<Box component="fieldset" m={0} p={0} style={{ border: 0, minWidth: 0 }}>
<Text component="legend" fw={500} size="sm" p={0}>
{label}
</Text>
<Stack gap="xs" mt={2}>
{description ? (
<Text size="sm" c="dimmed">
{description}
</Text>
) : null}
<SimpleGrid cols={{ base: 3, xs: 5 }} spacing="xs">
{cornerStyleValues.map((cornerStyle) => (
<SelectableCard
key={cornerStyle}
selected={value === cornerStyle}
onClick={() => onChange(cornerStyle)}
radius={cornerStyle}
p="xs"
aria-label={labels[cornerStyle]}
style={compact ? { minHeight: "auto", height: "auto" } : undefined}
>
<Box
h={previewHeight}
bg="var(--mantine-color-default-hover)"
style={{ borderRadius: `var(--mantine-radius-${cornerStyle})` }}
/>
<Text size="xs" ta="center" mt="xs">
{labels[cornerStyle]}
</Text>
</SelectableCard>
))}
</SimpleGrid>
</Stack>
</Box>
);
};
+2
View File
@@ -27,6 +27,8 @@ export { LanguageIcon } from "./language-icon";
export { LanguageCombobox } from "./language-combobox";
export { ColorSchemeCombobox } from "./color-scheme-combobox";
export { BoardColorInput } from "./board-color-input";
export { CornerStylePicker, cornerStyleValues } from "./corner-style-picker";
export type { CornerStyle } from "./corner-style-picker";
export { IntegrationMarquee } from "./integration-marquee";
export { Link } from "./link";
export {
@@ -0,0 +1,204 @@
import { z } from "zod/v4";
export const headerPreferencesVersion = 3 as const;
export const headerBuiltinItemIds = [
"logo",
"search",
"home",
"boardSwitcher",
"assistant",
"docker",
"settings",
"themeToggle",
"user",
] as const;
export const headerPreferenceItemIds = headerBuiltinItemIds;
export const headerItemIds = headerBuiltinItemIds;
export type HeaderBuiltinItemId = (typeof headerBuiltinItemIds)[number];
export type HeaderPreferenceItemId = HeaderBuiltinItemId;
export const headerZoneIds = ["left", "center", "right"] as const;
export const headerSearchDisplayValues = ["input", "icon"] as const;
export type HeaderZoneId = (typeof headerZoneIds)[number];
export type HeaderSearchDisplay = (typeof headerSearchDisplayValues)[number];
const headerBuiltinItemSchema = z
.object({
type: z.literal("builtin"),
id: z.enum(headerBuiltinItemIds),
})
.strict();
const headerBoardItemSchema = z
.object({
type: z.literal("board"),
boardId: z.string().min(1).max(255),
})
.strict();
export const headerItemSchema = z.discriminatedUnion("type", [headerBuiltinItemSchema, headerBoardItemSchema]);
export type HeaderBuiltinItem = z.infer<typeof headerBuiltinItemSchema>;
export type HeaderBoardItem = z.infer<typeof headerBoardItemSchema>;
export type HeaderItem = z.infer<typeof headerItemSchema>;
export type HeaderItemId = HeaderItem;
export type HeaderZones = Record<HeaderZoneId, HeaderItem[]>;
export const createBuiltinHeaderItem = (id: HeaderBuiltinItemId): HeaderBuiltinItem => ({ type: "builtin", id });
export const createBoardHeaderItem = (boardId: string): HeaderBoardItem => ({ type: "board", boardId });
export const getHeaderItemKey = (item: HeaderItem): string => {
if (item.type === "board") return `board:${item.boardId}`;
return `builtin:${item.id}`;
};
export const getHeaderItems = (zones: HeaderZones): HeaderItem[] => headerZoneIds.flatMap((zone) => zones[zone]);
export const getHeaderItemZone = (zones: HeaderZones, item: HeaderItem): HeaderZoneId | undefined => {
const itemKey = getHeaderItemKey(item);
return headerZoneIds.find((zone) => zones[zone].some((candidate) => getHeaderItemKey(candidate) === itemKey));
};
export const getDefaultHeaderZone = (item: HeaderItem): HeaderZoneId => {
if (item.type === "board") return "right";
if (item.id === "logo") return "left";
if (item.id === "search") return "center";
return "right";
};
const headerItemListSchema = z.array(headerItemSchema).max(100);
const headerZonesSchema = z
.object({
left: headerItemListSchema,
center: headerItemListSchema,
right: headerItemListSchema,
})
.strict()
.refine((zones) => getHeaderItems(zones).length > 0, "At least one header item is required")
.refine((zones) => {
const itemKeys = getHeaderItems(zones).map(getHeaderItemKey);
return new Set(itemKeys).size === itemKeys.length;
}, "Header items must be unique")
.refine(
(zones) => getHeaderItems(zones).some((item) => item.type === "builtin" && item.id === "user"),
"Account access must remain in the header",
);
export const headerPreferencesSchema = z
.object({
version: z.literal(headerPreferencesVersion),
visible: z.boolean(),
searchDisplay: z.enum(headerSearchDisplayValues),
zones: headerZonesSchema,
})
.strict();
export type HeaderPreferences = z.infer<typeof headerPreferencesSchema>;
const legacyHeaderItemIds = ["logo", "search", "boardSwitcher", "assistant", "docker", "user"] as const;
type LegacyHeaderItemId = (typeof legacyHeaderItemIds)[number];
const legacyHeaderItemListSchema = z
.array(z.enum(legacyHeaderItemIds))
.min(1)
.max(legacyHeaderItemIds.length)
.refine((items) => new Set(items).size === items.length, "Header items must be unique")
.refine((items) => items.includes("user"), "Account access must remain in the header");
const legacyHeaderPreferencesV1Schema = z
.object({
version: z.literal(1),
visible: z.boolean(),
items: legacyHeaderItemListSchema,
})
.strict();
const legacyHeaderPreferencesV2Schema = z
.object({
version: z.literal(2),
visible: z.boolean(),
zones: z
.object({
left: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
center: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
right: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
})
.strict()
.refine((zones) => {
const items = headerZoneIds.flatMap((zone) => zones[zone]);
return items.length > 0 && new Set(items).size === items.length && items.includes("user");
}, "Header items must be unique and include account access"),
})
.strict();
const migrateLegacyItems = (
visible: boolean,
zones: Record<HeaderZoneId, LegacyHeaderItemId[]>,
): HeaderPreferences => ({
version: headerPreferencesVersion,
visible,
searchDisplay: "input",
zones: {
left: zones.left.map(createBuiltinHeaderItem),
center: zones.center.map(createBuiltinHeaderItem),
right: zones.right.map(createBuiltinHeaderItem),
},
});
const migrateLegacyHeaderPreferencesV1 = (
legacy: z.infer<typeof legacyHeaderPreferencesV1Schema>,
): HeaderPreferences => {
const zones: Record<HeaderZoneId, LegacyHeaderItemId[]> = { left: [], center: [], right: [] };
for (const itemId of legacy.items) {
const item = createBuiltinHeaderItem(itemId);
zones[getDefaultHeaderZone(item)].push(itemId);
}
return migrateLegacyItems(legacy.visible, zones);
};
const migrateLegacyHeaderPreferencesV2 = (legacy: z.infer<typeof legacyHeaderPreferencesV2Schema>): HeaderPreferences =>
migrateLegacyItems(legacy.visible, legacy.zones);
export const headerPreferencesMutationSchema = z
.discriminatedUnion("version", [
headerPreferencesSchema,
legacyHeaderPreferencesV1Schema,
legacyHeaderPreferencesV2Schema,
])
.transform((preferences): HeaderPreferences => {
if (preferences.version === 1) return migrateLegacyHeaderPreferencesV1(preferences);
if (preferences.version === 2) return migrateLegacyHeaderPreferencesV2(preferences);
return preferences;
});
export const defaultHeaderPreferences = {
version: headerPreferencesVersion,
visible: true,
searchDisplay: "input",
zones: {
left: [createBuiltinHeaderItem("logo")],
center: [createBuiltinHeaderItem("search")],
right: [createBuiltinHeaderItem("user")],
},
} satisfies HeaderPreferences;
export const defaultHeaderPreferencesSerialized = JSON.stringify(defaultHeaderPreferences);
export const parseHeaderPreferences = (value: unknown): HeaderPreferences => {
try {
const decoded = typeof value === "string" ? JSON.parse(value) : value;
const parsed = headerPreferencesMutationSchema.safeParse(decoded);
if (parsed.success) return parsed.data;
return defaultHeaderPreferences;
} catch {
return defaultHeaderPreferences;
}
};
export const serializeHeaderPreferences = (value: HeaderPreferences) => JSON.stringify(value);
+32
View File
@@ -8,6 +8,38 @@ import { zodEnumFromArray } from "./enums";
import { createCustomErrorParams } from "./form/i18n";
import { nullableEmailSchema, optionalEmailSchema } from "./email";
export {
createBoardHeaderItem,
createBuiltinHeaderItem,
defaultHeaderPreferences,
getDefaultHeaderZone,
getHeaderItemKey,
getHeaderItems,
getHeaderItemZone,
headerBuiltinItemIds,
headerItemIds,
headerItemSchema,
headerPreferenceItemIds,
headerPreferencesMutationSchema,
headerPreferencesSchema,
headerPreferencesVersion,
headerSearchDisplayValues,
headerZoneIds,
parseHeaderPreferences,
} from "./header-preferences";
export type {
HeaderBoardItem,
HeaderBuiltinItem,
HeaderBuiltinItemId,
HeaderItem,
HeaderItemId,
HeaderPreferenceItemId,
HeaderPreferences,
HeaderSearchDisplay,
HeaderZoneId,
HeaderZones,
} from "./header-preferences";
// We always want the lowercase version of the username to compare it in a case-insensitive way
export const usernameSchema = z.string().trim().toLowerCase().min(3).max(255);
+6
View File
@@ -2599,6 +2599,9 @@ importers:
'@homarr/translation':
specifier: workspace:^0.1.0
version: link:../translation
zod:
specifier: 'catalog:'
version: 4.4.3
devDependencies:
'@homarr/tsconfig':
specifier: workspace:^0.1.0
@@ -2615,6 +2618,9 @@ importers:
'@homarr/server-settings':
specifier: workspace:^0.1.0
version: link:../server-settings
'@homarr/validation':
specifier: workspace:^0.1.0
version: link:../validation
'@mantine/dates':
specifier: 'catalog:'
version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)