feat: add configurable headers and instance branding (#6690)
This commit is contained in:
1 parent
97d8390b20
commit
9303dbd284
102 files changed
+12153
-609
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
) =>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ const MCP_TOOL_ALLOWLIST = {
|
||||
"mediaServer_getCurrentStreams",
|
||||
"patchmon_getStats",
|
||||
"serverSettings_getBoardSettings",
|
||||
"serverSettings_getBranding",
|
||||
"smartHome_entityDetails",
|
||||
"smartHome_entityState",
|
||||
"widgetSecrets_getConfiguredKinds",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user