feat: add configurable headers and instance branding

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

No files matched your search

@@ -0,0 +1,204 @@
import { z } from "zod/v4";
export const headerPreferencesVersion = 3 as const;
export const headerBuiltinItemIds = [
"logo",
"search",
"home",
"boardSwitcher",
"assistant",
"docker",
"settings",
"themeToggle",
"user",
] as const;
export const headerPreferenceItemIds = headerBuiltinItemIds;
export const headerItemIds = headerBuiltinItemIds;
export type HeaderBuiltinItemId = (typeof headerBuiltinItemIds)[number];
export type HeaderPreferenceItemId = HeaderBuiltinItemId;
export const headerZoneIds = ["left", "center", "right"] as const;
export const headerSearchDisplayValues = ["input", "icon"] as const;
export type HeaderZoneId = (typeof headerZoneIds)[number];
export type HeaderSearchDisplay = (typeof headerSearchDisplayValues)[number];
const headerBuiltinItemSchema = z
.object({
type: z.literal("builtin"),
id: z.enum(headerBuiltinItemIds),
})
.strict();
const headerBoardItemSchema = z
.object({
type: z.literal("board"),
boardId: z.string().min(1).max(255),
})
.strict();
export const headerItemSchema = z.discriminatedUnion("type", [headerBuiltinItemSchema, headerBoardItemSchema]);
export type HeaderBuiltinItem = z.infer<typeof headerBuiltinItemSchema>;
export type HeaderBoardItem = z.infer<typeof headerBoardItemSchema>;
export type HeaderItem = z.infer<typeof headerItemSchema>;
export type HeaderItemId = HeaderItem;
export type HeaderZones = Record<HeaderZoneId, HeaderItem[]>;
export const createBuiltinHeaderItem = (id: HeaderBuiltinItemId): HeaderBuiltinItem => ({ type: "builtin", id });
export const createBoardHeaderItem = (boardId: string): HeaderBoardItem => ({ type: "board", boardId });
export const getHeaderItemKey = (item: HeaderItem): string => {
if (item.type === "board") return `board:${item.boardId}`;
return `builtin:${item.id}`;
};
export const getHeaderItems = (zones: HeaderZones): HeaderItem[] => headerZoneIds.flatMap((zone) => zones[zone]);
export const getHeaderItemZone = (zones: HeaderZones, item: HeaderItem): HeaderZoneId | undefined => {
const itemKey = getHeaderItemKey(item);
return headerZoneIds.find((zone) => zones[zone].some((candidate) => getHeaderItemKey(candidate) === itemKey));
};
export const getDefaultHeaderZone = (item: HeaderItem): HeaderZoneId => {
if (item.type === "board") return "right";
if (item.id === "logo") return "left";
if (item.id === "search") return "center";
return "right";
};
const headerItemListSchema = z.array(headerItemSchema).max(100);
const headerZonesSchema = z
.object({
left: headerItemListSchema,
center: headerItemListSchema,
right: headerItemListSchema,
})
.strict()
.refine((zones) => getHeaderItems(zones).length > 0, "At least one header item is required")
.refine((zones) => {
const itemKeys = getHeaderItems(zones).map(getHeaderItemKey);
return new Set(itemKeys).size === itemKeys.length;
}, "Header items must be unique")
.refine(
(zones) => getHeaderItems(zones).some((item) => item.type === "builtin" && item.id === "user"),
"Account access must remain in the header",
);
export const headerPreferencesSchema = z
.object({
version: z.literal(headerPreferencesVersion),
visible: z.boolean(),
searchDisplay: z.enum(headerSearchDisplayValues),
zones: headerZonesSchema,
})
.strict();
export type HeaderPreferences = z.infer<typeof headerPreferencesSchema>;
const legacyHeaderItemIds = ["logo", "search", "boardSwitcher", "assistant", "docker", "user"] as const;
type LegacyHeaderItemId = (typeof legacyHeaderItemIds)[number];
const legacyHeaderItemListSchema = z
.array(z.enum(legacyHeaderItemIds))
.min(1)
.max(legacyHeaderItemIds.length)
.refine((items) => new Set(items).size === items.length, "Header items must be unique")
.refine((items) => items.includes("user"), "Account access must remain in the header");
const legacyHeaderPreferencesV1Schema = z
.object({
version: z.literal(1),
visible: z.boolean(),
items: legacyHeaderItemListSchema,
})
.strict();
const legacyHeaderPreferencesV2Schema = z
.object({
version: z.literal(2),
visible: z.boolean(),
zones: z
.object({
left: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
center: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
right: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
})
.strict()
.refine((zones) => {
const items = headerZoneIds.flatMap((zone) => zones[zone]);
return items.length > 0 && new Set(items).size === items.length && items.includes("user");
}, "Header items must be unique and include account access"),
})
.strict();
const migrateLegacyItems = (
visible: boolean,
zones: Record<HeaderZoneId, LegacyHeaderItemId[]>,
): HeaderPreferences => ({
version: headerPreferencesVersion,
visible,
searchDisplay: "input",
zones: {
left: zones.left.map(createBuiltinHeaderItem),
center: zones.center.map(createBuiltinHeaderItem),
right: zones.right.map(createBuiltinHeaderItem),
},
});
const migrateLegacyHeaderPreferencesV1 = (
legacy: z.infer<typeof legacyHeaderPreferencesV1Schema>,
): HeaderPreferences => {
const zones: Record<HeaderZoneId, LegacyHeaderItemId[]> = { left: [], center: [], right: [] };
for (const itemId of legacy.items) {
const item = createBuiltinHeaderItem(itemId);
zones[getDefaultHeaderZone(item)].push(itemId);
}
return migrateLegacyItems(legacy.visible, zones);
};
const migrateLegacyHeaderPreferencesV2 = (legacy: z.infer<typeof legacyHeaderPreferencesV2Schema>): HeaderPreferences =>
migrateLegacyItems(legacy.visible, legacy.zones);
export const headerPreferencesMutationSchema = z
.discriminatedUnion("version", [
headerPreferencesSchema,
legacyHeaderPreferencesV1Schema,
legacyHeaderPreferencesV2Schema,
])
.transform((preferences): HeaderPreferences => {
if (preferences.version === 1) return migrateLegacyHeaderPreferencesV1(preferences);
if (preferences.version === 2) return migrateLegacyHeaderPreferencesV2(preferences);
return preferences;
});
export const defaultHeaderPreferences = {
version: headerPreferencesVersion,
visible: true,
searchDisplay: "input",
zones: {
left: [createBuiltinHeaderItem("logo")],
center: [createBuiltinHeaderItem("search")],
right: [createBuiltinHeaderItem("user")],
},
} satisfies HeaderPreferences;
export const defaultHeaderPreferencesSerialized = JSON.stringify(defaultHeaderPreferences);
export const parseHeaderPreferences = (value: unknown): HeaderPreferences => {
try {
const decoded = typeof value === "string" ? JSON.parse(value) : value;
const parsed = headerPreferencesMutationSchema.safeParse(decoded);
if (parsed.success) return parsed.data;
return defaultHeaderPreferences;
} catch {
return defaultHeaderPreferences;
}
};
export const serializeHeaderPreferences = (value: HeaderPreferences) => JSON.stringify(value);
+32
View File
@@ -8,6 +8,38 @@ import { zodEnumFromArray } from "./enums";
import { createCustomErrorParams } from "./form/i18n";
import { nullableEmailSchema, optionalEmailSchema } from "./email";
export {
createBoardHeaderItem,
createBuiltinHeaderItem,
defaultHeaderPreferences,
getDefaultHeaderZone,
getHeaderItemKey,
getHeaderItems,
getHeaderItemZone,
headerBuiltinItemIds,
headerItemIds,
headerItemSchema,
headerPreferenceItemIds,
headerPreferencesMutationSchema,
headerPreferencesSchema,
headerPreferencesVersion,
headerSearchDisplayValues,
headerZoneIds,
parseHeaderPreferences,
} from "./header-preferences";
export type {
HeaderBoardItem,
HeaderBuiltinItem,
HeaderBuiltinItemId,
HeaderItem,
HeaderItemId,
HeaderPreferenceItemId,
HeaderPreferences,
HeaderSearchDisplay,
HeaderZoneId,
HeaderZones,
} from "./header-preferences";
// We always want the lowercase version of the username to compare it in a case-insensitive way
export const usernameSchema = z.string().trim().toLowerCase().min(3).max(255);