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

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

No files matched your search

+3 -3
View File
@@ -495,7 +495,7 @@ export const assistantRouter = createTRPCRouter({
models: [
{
id: "homarr-assistant",
name: "Homarr Assistant (Demo)",
name: "Assistant (Demo)",
description: null,
contextLength: null,
promptPrice: null,
@@ -507,7 +507,7 @@ export const assistantRouter = createTRPCRouter({
};
}
if (!configuration?.enabled || !configuration.modelId) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Assistant is not configured." });
}
const discoveredModels = await getAssistantModelsAsync(configuration).catch(() => []);
const resolvedDefaultId = resolveAssistantModelId(discoveredModels, configuration.modelId) ?? configuration.modelId;
@@ -708,7 +708,7 @@ export const assistantRouter = createTRPCRouter({
getConfigurationAsync(ctx.db),
]);
if (!configuration?.enabled || !configuration.modelId) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Assistant is not configured." });
}
const selectedModel = await getSelectedModelDetailsAsync(configuration, input.modelId).catch(() => null);
if (input.modelId !== configuration.modelId && !selectedModel) {
+30 -3
View File
@@ -87,7 +87,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();
@@ -140,6 +142,7 @@ const searchAccessibleBoardsAsync = async (
group: {
with: {
boardPermissions: {},
permissions: {},
},
},
},
@@ -300,9 +303,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: {
@@ -352,7 +360,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));
@@ -2577,6 +2585,7 @@ const getBoardAccessContextAsync = async (db: Database, userId: string | undefin
group: {
with: {
boardPermissions: {},
permissions: {},
},
},
},
@@ -2600,6 +2609,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"],
) =>
+40 -3
View File
@@ -10,7 +10,12 @@ 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,
parseBrandingSettings,
} from "@homarr/server-settings";
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../trpc";
@@ -22,7 +27,14 @@ 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(),
});
export const serverSettingsRouter = createTRPCRouter({
getCulture: publicProcedure.query(async ({ ctx }) => {
return await getServerSettingByKeyAsync(ctx.db, "culture");
@@ -30,6 +42,14 @@ export const serverSettingsRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await getServerSettingsAsync(ctx.db);
}),
getBranding: publicProcedure
.meta({
mcp: { enabled: true, description: "Returns the public instance branding configuration." },
})
.query(async ({ ctx }) => {
const branding = await getServerSettingByKeyAsync(ctx.db, "branding");
return parseBrandingSettings(branding);
}),
getBoardSettings: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
@@ -100,10 +120,27 @@ 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;
}
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]);
}),
});
@@ -79,7 +79,7 @@ describe("saveSettings", () => {
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
expect(dbSettings.filter(({ settingKey }) => settingKey === "analytics")).toStrictEqual([
{
settingKey: "analytics",
value: stringify({
@@ -124,7 +124,7 @@ describe("board settings API", () => {
forceDisableStatus: true,
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([
{
settingKey: "board",
value: stringify(result),
@@ -155,7 +155,7 @@ describe("board settings API", () => {
enableStatusByDefault: false,
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([
{
settingKey: "board",
value: stringify(result),
+68 -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,63 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.id));
}),
changeHeaderPreferences: protectedProcedure
.meta({
openapi: {
method: "PATCH",
path: "/api/users/header-preferences",
tags: ["users"],
protect: true,
},
mcp: {
enabled: true,
description:
"Update the header layout preferences of a user. REQUIRED: id (user ID), headerPreferences (zones for left, center and right, visible flag, searchDisplay and logoDisplay). Admins can change any user; other users can only change their own. Board shortcut items must reference boards the target user can view",
},
})
.input(z.object({ id: z.string(), headerPreferences: headerPreferencesMutationSchema }))
.output(z.void())
.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 +893,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
@@ -65,6 +65,7 @@ const MCP_TOOL_ALLOWLIST = {
"mediaServer_getCurrentStreams",
"patchmon_getStats",
"serverSettings_getBoardSettings",
"serverSettings_getBranding",
"smartHome_entityDetails",
"smartHome_entityState",
"widgetSecrets_getConfiguredKinds",
+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,4 @@
ALTER TABLE `user` ADD `header_preferences` text DEFAULT ('{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}') NOT NULL;
--> statement-breakpoint
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"}}');
File diff suppressed because it is too large. Load diff
@@ -337,6 +337,13 @@
"when": 1786217088950,
"tag": "0047_add_assistant_tables",
"breakpoints": true
},
{
"idx": 48,
"version": "5",
"when": 1787556966768,
"tag": "0048_add_header_preferences_and_branding",
"breakpoints": true
}
]
}
@@ -0,0 +1,5 @@
ALTER TABLE "user" ADD COLUMN "header_preferences" text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}' NOT NULL;
--> statement-breakpoint
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;
File diff suppressed because it is too large. Load diff
@@ -99,6 +99,13 @@
"when": 1786217088954,
"tag": "0013_add_assistant_tables",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1787556966592,
"tag": "0014_add_header_preferences_and_branding",
"breakpoints": true
}
]
}
@@ -0,0 +1,5 @@
ALTER TABLE `user` ADD `header_preferences` text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}' NOT NULL;
--> statement-breakpoint
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;
File diff suppressed because it is too large. Load diff
@@ -323,6 +323,13 @@
"when": 1786217088950,
"tag": "0045_add_assistant_tables",
"breakpoints": true
},
{
"idx": 46,
"version": "6",
"when": 1787556956665,
"tag": "0046_add_header_preferences_and_branding",
"breakpoints": true
}
]
}
+16 -4
View File
@@ -1,7 +1,7 @@
import SuperJSON from "superjson";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { defaultServerSettings, defaultServerSettingsKeys, parseBrandingSettings } from "@homarr/server-settings";
import type { Database } from "..";
import { eq } from "..";
@@ -18,10 +18,15 @@ 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;
}
acc[settingKey] = {
...defaultServerSettings[settingKey],
...SuperJSON.parse(setting.value),
};
...parsedSetting,
} as never;
return acc;
}, {} as ServerSettings);
};
@@ -35,7 +40,14 @@ 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];
}
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,6 +1,6 @@
"use client";
import type { FocusEventHandler, KeyboardEventHandler } from "react";
import type { FocusEventHandler, KeyboardEventHandler, ReactNode } from "react";
import { startTransition, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
@@ -48,6 +48,7 @@ interface IconPickerProps {
onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler;
label?: string;
description?: ReactNode;
placeholder?: string;
suggestedSearch?: string | null;
withAsterisk?: boolean;
@@ -63,6 +64,7 @@ export const IconPicker = ({
onBlur,
withAsterisk = true,
label,
description,
placeholder,
suggestedSearch,
}: IconPickerProps) => {
@@ -272,6 +274,7 @@ export const IconPicker = ({
(query.data
? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
: tCommon("iconPicker.headerLoading"));
const inputDescription = description === undefined ? tCommon("iconPicker.description") : description;
return (
<Combobox
@@ -359,7 +362,7 @@ export const IconPicker = ({
withAsterisk={withAsterisk}
error={error}
label={label ?? tCommon("iconPicker.label")}
description={tCommon("iconPicker.description")}
description={inputDescription}
styles={{
description: { color: "var(--mantine-color-text)" },
input: { color: "var(--mantine-color-text)" },
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import type { CSSProperties, ReactNode } from "react";
import { Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { OnboardingBackdrop } from "./onboarding-backdrop";
@@ -11,17 +11,72 @@ interface OnboardingAuthShellProps {
icon: ReactNode;
children: ReactNode;
footer?: ReactNode;
appName?: string;
showAppName?: boolean;
showAppLogo?: boolean;
primaryColor?: string;
secondaryColor?: string;
wordmarkPrimaryColor?: string;
wordmarkSecondaryColor?: string;
logoImageUrl?: string;
backgroundImageUrl?: string;
backgroundOverlay?: number;
radius?: "xs" | "sm" | "md" | "lg" | "xl";
}
export const OnboardingAuthShell = ({ title, description, icon, children, footer }: OnboardingAuthShellProps) => (
<main className={classes.page}>
export const OnboardingAuthShell = ({
title,
description,
icon,
children,
footer,
appName,
showAppName = false,
showAppLogo = false,
primaryColor,
secondaryColor,
wordmarkPrimaryColor,
wordmarkSecondaryColor,
logoImageUrl,
backgroundImageUrl,
backgroundOverlay = 0.55,
radius = "lg",
}: OnboardingAuthShellProps) => (
<main
className={classes.page}
data-background-image={backgroundImageUrl ? true : undefined}
style={
{
"--studio-glow-color": primaryColor,
"--studio-secondary-glow-color": secondaryColor,
"--auth-background-overlay": backgroundOverlay,
} as CSSProperties
}
>
{backgroundImageUrl ? (
<div
aria-hidden
className={classes.authBackground}
style={{ backgroundImage: `url(${JSON.stringify(backgroundImageUrl)})` }}
/>
) : null}
<OnboardingBackdrop />
<div className={`${classes.shell} ${classes.authShell}`}>
<Stack className={classes.authLayout} gap="xl" align="center" justify="center">
<div className={classes.authBrand}>
<OnboardingWordmark large />
</div>
<Paper className={`${classes.studio} ${classes.authCard}`} radius="lg" p={{ base: "lg", sm: "xl" }}>
{showAppName || showAppLogo ? (
<div className={classes.authBrand}>
<OnboardingWordmark
large
appName={appName}
showAppName={showAppName}
showAppLogo={showAppLogo}
primaryColor={wordmarkPrimaryColor}
secondaryColor={wordmarkSecondaryColor}
logoImageUrl={logoImageUrl}
/>
</div>
) : null}
<Paper className={`${classes.studio} ${classes.authCard}`} radius={radius} p={{ base: "lg", sm: "xl" }}>
<Stack gap="xl">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon size="xl" radius="lg" variant="light">
@@ -88,6 +88,33 @@
max-width: min(92vw, 42rem);
}
.customBrand {
display: flex;
align-items: center;
justify-content: center;
gap: var(--mantine-spacing-md);
max-width: min(92vw, 42rem);
color: var(--mantine-color-text);
font-size: clamp(2rem, 6vw, 4rem);
font-weight: 750;
line-height: 1;
letter-spacing: -0.04em;
}
.customBrand[data-large="true"] {
font-size: clamp(2.5rem, 8vw, 5.5rem);
}
.customBrandImage {
width: auto;
height: clamp(4rem, 11vw, 7rem);
object-fit: contain;
}
.customBrand[data-large="true"] .customBrandImage {
height: clamp(5rem, 16vw, 9rem);
}
.celebratingWordmark {
animation: wordmarkJump 360ms ease-out;
}
@@ -140,6 +167,25 @@
padding-block: clamp(1rem, 4vw, 3rem) calc(clamp(1rem, 4vw, 3rem) + env(safe-area-inset-bottom));
}
.authBackground {
position: fixed;
z-index: 0;
inset: 0;
background-position: center;
background-size: cover;
}
.authBackground::after {
position: absolute;
inset: 0;
content: "";
background: rgb(0 0 0 / var(--auth-background-overlay, 0.55));
}
.page[data-background-image] .marqueeBackdrop {
opacity: 0.14;
}
.authLayout {
width: min(100%, 36rem);
margin: auto;
@@ -9,6 +9,10 @@ import classes from "./onboarding-studio.module.css";
import { useOnboardingSounds } from "./use-onboarding-sounds";
interface OnboardingWordmarkProps {
appName?: string;
logoImageUrl?: string;
showAppName?: boolean;
showAppLogo?: boolean;
primaryColor?: string;
secondaryColor?: string;
large?: boolean;
@@ -16,7 +20,15 @@ interface OnboardingWordmarkProps {
const defaultWordmarkUrl = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.svg";
export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false }: OnboardingWordmarkProps) => {
export const OnboardingWordmark = ({
appName,
logoImageUrl,
showAppName,
showAppLogo,
primaryColor,
secondaryColor,
large = false,
}: OnboardingWordmarkProps) => {
const [celebrating, setCelebrating] = useState(false);
const sounds = useOnboardingSounds();
const className = `${classes.wordmark} ${classes.onboardingWordmark} ${large ? classes.welcomeWordmark : ""} ${celebrating ? classes.celebratingWordmark : ""}`;
@@ -52,14 +64,36 @@ export const OnboardingWordmark = ({ primaryColor, secondaryColor, large = false
}
};
const hasVisibilityControls = showAppName !== undefined || showAppLogo !== undefined;
const resolvedAppName = appName?.trim() || "Homarr";
const shouldRenderDefaultWordmark = showAppLogo && showAppName && !logoImageUrl && resolvedAppName === "Homarr";
if (hasVisibilityControls && !shouldRenderDefaultWordmark) {
const visibleLogoImageUrl = showAppLogo ? (logoImageUrl ?? "/logo/logo.png") : undefined;
if (!visibleLogoImageUrl && !showAppName) return null;
return (
<div className={classes.customBrand} data-large={large || undefined} aria-label={resolvedAppName}>
{visibleLogoImageUrl ? (
<img
className={classes.customBrandImage}
src={visibleLogoImageUrl}
alt={showAppName ? "" : resolvedAppName}
/>
) : null}
{showAppName ? <span>{resolvedAppName}</span> : null}
</div>
);
}
if (primaryColor === undefined && secondaryColor === undefined) {
return <img src={defaultWordmarkUrl} alt="Homarr" width={1477} height={1054} className={className} />;
return <img src={defaultWordmarkUrl} alt={resolvedAppName} width={1477} height={1054} className={className} />;
}
return (
<HomarrWordmarkLight
role="img"
aria-label="Homarr"
aria-label={resolvedAppName}
style={colors}
className={className}
onMouseEnter={(event) => celebrate(event.currentTarget)}
+24 -33
View File
@@ -34,7 +34,6 @@ import {
UnstyledButton,
useMantineColorScheme,
} from "@mantine/core";
import type { MantineSize } from "@mantine/core";
import {
IconAdjustments,
IconAlertCircle,
@@ -91,7 +90,16 @@ import {
import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications";
import type { SupportedLanguage } from "@homarr/translation";
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import { BoardColorInput, ColorSchemeCombobox, IntegrationAvatar, LanguageCombobox, Link } from "@homarr/ui";
import type { CornerStyle } from "@homarr/ui";
import {
BoardColorInput,
ColorSchemeCombobox,
CornerStylePicker,
cornerStyleValues,
IntegrationAvatar,
LanguageCombobox,
Link,
} from "@homarr/ui";
import { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid";
import type { OnboardingStudioProps } from "./types";
@@ -146,7 +154,6 @@ const sectionDefinitions = [
{ id: "review", icon: IconCheck },
] as const;
const radiusValues = ["xs", "sm", "md", "lg", "xl"] as const;
const initialPrimaryColor = "#fa5252";
const initialSecondaryColor = "#fd7e14";
const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = [];
@@ -176,7 +183,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
const [boardName, setBoardName] = useState(initialBoard?.name ?? "dashboard");
const [primaryColor, setPrimaryColor] = useState(initialPrimaryColor);
const [secondaryColor, setSecondaryColor] = useState(initialSecondaryColor);
const [itemRadius, setItemRadius] = useState<MantineSize>("lg");
const [itemRadius, setItemRadius] = useState<CornerStyle>("lg");
const [columnCount, setColumnCount] = useState(10);
const [leftSidebar, setLeftSidebar] = useState(false);
const [rightSidebar, setRightSidebar] = useState(false);
@@ -797,8 +804,8 @@ interface StudioSectionProps {
setPrimaryColor: (value: string) => void;
secondaryColor: string;
setSecondaryColor: (value: string) => void;
itemRadius: MantineSize;
setItemRadius: (value: MantineSize) => void;
itemRadius: CornerStyle;
setItemRadius: (value: CornerStyle) => void;
columnCount: number;
setColumnCount: (value: number) => void;
leftSidebar: boolean;
@@ -1452,32 +1459,16 @@ const BoardBuilder = (props: StudioSectionProps) => {
</Button>
) : null}
</Stack>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("radius")}
</Text>
<OnboardingFloatingControl
ariaLabel={t("radius")}
value={props.itemRadius}
onChange={props.setItemRadius}
options={radiusValues.map((value) => ({ value, label: value.toUpperCase() }))}
/>
<Group gap="xs" aria-hidden>
{radiusValues.map((radius) => (
<div
key={radius}
style={{
flex: 1,
height: 34,
borderRadius: `var(--mantine-radius-${radius})`,
background: "var(--mantine-color-default)",
border: "1px solid var(--mantine-color-default-border)",
opacity: props.itemRadius === radius ? 1 : 0.55,
}}
/>
))}
</Group>
</Stack>
<CornerStylePicker
label={t("radius")}
value={props.itemRadius}
labels={
Object.fromEntries(
cornerStyleValues.map((cornerStyle) => [cornerStyle, t(`radiusOptions.${cornerStyle}`)]),
) as Record<CornerStyle, string>
}
onChange={props.setItemRadius}
/>
</Stack>
<Stack gap="lg">
@@ -1543,7 +1534,7 @@ const BoardPreview = ({
appCount,
}: {
ariaLabel: string;
itemRadius: MantineSize;
itemRadius: CornerStyle;
baseColumnCount: number;
leftSidebar: boolean;
rightSidebar: boolean;
+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",
+110
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,120 @@ export const defaultServerSettingsKeys = [
"board",
"user",
"appearance",
"branding",
"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(),
});
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",
};
const hasDefaultBrandingColors = (branding: Pick<BrandingSettings, "primaryColor" | "secondaryColor">) =>
branding.primaryColor.toLowerCase() === defaultBrandingSettings.primaryColor &&
branding.secondaryColor.toLowerCase() === defaultBrandingSettings.secondaryColor;
export const getBrandingColorOverrides = (
branding: Pick<BrandingSettings, "primaryColor" | "secondaryColor">,
): Partial<Pick<BrandingSettings, "primaryColor" | "secondaryColor">> => {
if (hasDefaultBrandingColors(branding)) return {};
return {
primaryColor: branding.primaryColor,
secondaryColor: branding.secondaryColor,
};
};
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 defaultServerSettings = {
analytics: {
enableGeneral: true,
@@ -36,6 +145,7 @@ export const defaultServerSettings = {
appearance: {
defaultColorScheme: "auto" as ColorScheme,
},
branding: defaultBrandingSettings,
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:",
+28 -13
View File
@@ -1,19 +1,30 @@
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"];
};
export interface PublicServerSettings {
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
@@ -22,6 +33,7 @@ export interface PublicServerSettings {
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
>;
user: Pick<ServerSettings["user"], "enableGravatar">;
branding: ServerSettings["branding"];
}
export type UserSettings = Pick<
@@ -34,6 +46,7 @@ export type UserSettings = Pick<
| "ddgBangs"
| "pingIconsEnabled"
| "enableRightClickOnWidgets"
| "headerPreferences"
>;
export const createSettings = ({
@@ -51,7 +64,9 @@ 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),
});
+157 -9
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}"
}
},
@@ -1847,13 +1856,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": "Assistant",
"assistantThinking": "Thinking",
"modifier": "Mod"
}
@@ -5336,7 +5346,8 @@
}
},
"primaryColor": {
"label": "Primary color"
"label": "Primary color",
"locked": "Your administrator keeps the instance brand color on every board."
},
"secondaryColor": {
"label": "Secondary color"
@@ -5676,6 +5687,65 @@
"setting": {
"general": {
"title": "General",
"header": {
"title": "Header studio",
"badge": "Personal",
"description": "Drag controls anywhere in the row. Left, Center, and Right stay fixed while controls shift around them.",
"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}",
"dropIntoZone": "Move into the {zone} section"
},
"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.",
"requiredAlways": "This control is required, but you can move it anywhere in the header.",
"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"
},
"logoDisplay": {
"logo": "Logo only",
"logoAndText": "Logo + text"
}
},
"items": {
"logo": "Logo",
"search": "Search",
"home": "Home board",
"boardSwitcher": "Boards",
"assistant": "Assistant",
"docker": "Docker",
"boardEdit": "Edit board",
"boardSettings": "Board settings",
"settings": "Your settings",
"themeToggle": "Toggle theme",
"user": "Account",
"boardUnavailable": "Board unavailable"
}
},
"item": {
"language": "Language & Region",
"board": {
@@ -5874,6 +5944,80 @@
"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"
}
},
"culture": {
"title": "Culture",
"defaultLocale": {
@@ -6318,7 +6462,7 @@
"openSpotlightApps": "Open app search",
"openSpotlightAssistant": "Open Assistant mode",
"openSpotlightPreferences": "Open preferences mode",
"openAssistant": "Open Homarr Assistant"
"openAssistant": "Open Assistant"
},
"note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS"
}
@@ -6802,7 +6946,7 @@
},
"assistant": {
"label": "Assistant",
"placeholder": "Ask Homarr Assistant"
"placeholder": "Ask Assistant"
},
"external": {
"label": "Web",
@@ -7508,12 +7652,16 @@
}
}
},
"metadata": {
"description": "A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
"dashboardTitle": "{appName} Dashboard"
},
"assistant": {
"title": "Homarr Assistant",
"open": "Open Homarr Assistant",
"title": "Assistant",
"open": "Open Assistant",
"close": "Close assistant",
"minimize": "Minimize assistant",
"spotlight": "Ask Homarr Assistant",
"spotlight": "Ask Assistant",
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
"spotlightPrompt": {
"title": "Ask Assistant",
@@ -7525,7 +7673,7 @@
"checking": "Checking whether the assistant is available…",
"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."
"signIn": "Sign in to use Assistant."
},
"newConversation": "New conversation",
"conversations": "Conversations",
@@ -7568,7 +7716,7 @@
"prompt": "Help me improve a board's appearance or custom CSS. Show me my boards first, then preserve my current settings while we make the change."
}
},
"composerPlaceholder": "Ask Homarr Assistant…",
"composerPlaceholder": "Ask Assistant…",
"runtime": {
"model": "Model",
"searchModels": "Search models",
@@ -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,237 @@
import { z } from "zod/v4";
export const headerPreferencesVersion = 3 as const;
export const headerBuiltinItemIds = [
"logo",
"search",
"home",
"boardSwitcher",
"assistant",
"docker",
"boardEdit",
"boardSettings",
"settings",
"themeToggle",
"user",
] as const;
export const requiredHeaderBuiltinItemIds = ["boardEdit", "boardSettings", "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 const headerLogoDisplayValues = ["logo", "logoAndText"] as const;
export type HeaderZoneId = (typeof headerZoneIds)[number];
export type HeaderSearchDisplay = (typeof headerSearchDisplayValues)[number];
export type HeaderLogoDisplay = (typeof headerLogoDisplayValues)[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 isRequiredHeaderItem = (item: HeaderItem) =>
item.type === "builtin" && requiredHeaderBuiltinItemIds.some((itemId) => itemId === item.id);
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),
logoDisplay: z.enum(headerLogoDisplayValues).default("logo"),
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",
logoDisplay: "logo",
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);
const includeRequiredHeaderItems = (preferences: HeaderPreferences): HeaderPreferences => {
const itemKeys = new Set(getHeaderItems(preferences.zones).map(getHeaderItemKey));
const missingItems = requiredHeaderBuiltinItemIds
.map(createBuiltinHeaderItem)
.filter((item) => !itemKeys.has(getHeaderItemKey(item)));
if (missingItems.length === 0) return preferences;
return {
...preferences,
zones: {
...preferences.zones,
right: [...preferences.zones.right, ...missingItems],
},
};
};
export const headerPreferencesMutationSchema = z
.discriminatedUnion("version", [
headerPreferencesSchema,
legacyHeaderPreferencesV1Schema,
legacyHeaderPreferencesV2Schema,
])
.transform((preferences): HeaderPreferences => {
if (preferences.version === 1) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV1(preferences));
if (preferences.version === 2) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV2(preferences));
return includeRequiredHeaderItems(preferences);
});
export const defaultHeaderPreferences = {
version: headerPreferencesVersion,
visible: true,
searchDisplay: "input",
logoDisplay: "logo",
zones: {
left: [createBuiltinHeaderItem("logo")],
center: [createBuiltinHeaderItem("search")],
right: [
createBuiltinHeaderItem("boardEdit"),
createBuiltinHeaderItem("boardSettings"),
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(includeRequiredHeaderItems(value));
+36
View File
@@ -8,6 +8,42 @@ 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,
headerLogoDisplayValues,
headerPreferenceItemIds,
headerPreferencesMutationSchema,
headerPreferencesSchema,
headerPreferencesVersion,
headerSearchDisplayValues,
headerZoneIds,
isRequiredHeaderItem,
parseHeaderPreferences,
requiredHeaderBuiltinItemIds,
} from "./header-preferences";
export type {
HeaderBoardItem,
HeaderBuiltinItem,
HeaderBuiltinItemId,
HeaderItem,
HeaderItemId,
HeaderLogoDisplay,
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);