feat: refine configurable header and branding

This commit is contained in:
ajnart committed 2026-08-24 00:35:53 +02:00
1 parent 803d590a16
commit f8d3d5893a
47 files changed
+234 -8634

No files matched your search

@@ -21,7 +21,7 @@ import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { ClientBoard } from "./_client";
import { BoardContentHeaderActions } from "./_header-actions";
import { BoardContentEditAction, BoardContentSettingsAction } from "./_header-actions";
const logger = createLogger({ module: "createBoardContentPage" });
const getQueryClient = cache(makeQueryClient);
@@ -37,7 +37,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
@@ -39,10 +39,9 @@ const loadBoardEditorAsync = async () =>
]);
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const t = useI18n("board");
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) return null;
@@ -50,20 +49,27 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return (
<>
{isEditMode && <BoardEditActions />}
<EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
)}
</>
);
};
export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const board = useRequiredBoard();
const t = useI18n("board");
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess || demoReadOnly) return null;
return (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
);
};
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
@@ -28,13 +28,17 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" });
interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
headerActions?: JSX.Element;
headerBoardEditAction?: JSX.Element;
headerBoardSettingsAction?: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
withTour?: boolean;
}
export const createBoardLayout = <TParams extends Params>({
headerActions,
headerBoardEditAction,
headerBoardSettingsAction,
getInitialBoardAsync: getInitialBoard,
withTour = false,
}: CreateBoardLayoutProps<TParams>) => {
@@ -108,6 +112,8 @@ export const createBoardLayout = <TParams extends Params>({
<MainHeader
logo={<BoardLogo size={appShellLogoHeight} />}
actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
hasNavigation={false}
/>
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
+2 -8
View File
@@ -2,7 +2,6 @@ import { headers } from "next/headers";
import { getOnboardingClaimTokenFromCookieHeader, isOnboardingClaimValidAsync } from "@homarr/api/onboarding-claim";
import { normalizeOnboardingStep } from "@homarr/api/onboarding-step";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { extractBaseUrlFromHeaders } from "@homarr/common";
@@ -20,12 +19,11 @@ import { getPackageVersion } from "~/versions/package-reader";
import { AssistantConfiguration } from "../manage/assistant/_components/assistant-configuration";
export default async function InitPage() {
const [state, session, requestHeaders, firstUser, serverSettings] = await Promise.all([
const [state, session, requestHeaders, firstUser] = await Promise.all([
db.query.onboarding.findFirst(),
auth(),
headers(),
db.query.users.findFirst({ columns: { id: true } }),
getRscServerSettingsAsync(),
]);
const canConfigurePrivileged = session?.user.permissions.includes("admin") ?? false;
const onboardingClaim = getOnboardingClaimTokenFromCookieHeader(requestHeaders.get("cookie"));
@@ -74,11 +72,7 @@ export default async function InitPage() {
availableBoards: availableBoards.map(({ id, name }) => ({ id, name })),
}}
sqliteRestore={databaseDriver === "sqlite" ? <DatabaseRestoreFlow variant="standalone" /> : undefined}
assistantConfiguration={
canConfigurePrivileged && serverSettings.featureControls.assistantEnabled ? (
<AssistantConfiguration />
) : undefined
}
assistantConfiguration={canConfigurePrivileged ? <AssistantConfiguration /> : undefined}
/>
);
}
+1 -6
View File
@@ -113,11 +113,7 @@ export default async function Layout(props: {
})
: null,
);
const assistantAvailabilityPromise: Promise<AssistantAvailability> = Promise.all([
sessionPromise,
serverSettingsPromise,
]).then(([session, serverSettings]) => {
if (!serverSettings.featureControls.assistantEnabled) return "disabled";
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => {
if (!session) return "unauthenticated";
return api.assistant
.getAvailability()
@@ -163,7 +159,6 @@ export default async function Layout(props: {
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
user: { enableGravatar: serverSettings.user.enableGravatar },
branding: serverSettings.branding,
featureControls: serverSettings.featureControls,
}}
{...innerProps}
/>
@@ -1,7 +1,6 @@
import { notFound } from "next/navigation";
import { Text } from "@mantine/core";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next";
import { getI18n } from "@homarr/translation/server";
@@ -17,9 +16,9 @@ export async function generateMetadata() {
}
export default async function AssistantPage() {
const [session, serverSettings] = await Promise.all([auth(), getRscServerSettingsAsync()]);
const session = await auth();
if (!serverSettings.featureControls.assistantEnabled || !session?.user.permissions.includes("admin")) {
if (!session?.user.permissions.includes("admin")) {
notFound();
}
@@ -29,7 +29,6 @@ import {
} from "@tabler/icons-react";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -62,12 +61,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
return false;
}
});
const [t, tEntities, session, shouldRunManageTour, serverSettings] = await Promise.all([
const [t, tEntities, session, shouldRunManageTour] = await Promise.all([
getI18n("management.navbar"),
getI18n("common.entity"),
sessionPromise,
shouldRunManageTourPromise,
getRscServerSettingsAsync(),
]);
const appsAccess = getAppsSectionAccess(session);
const integrationsAccess = await getIntegrationsSectionAccessAsync(session);
@@ -203,7 +201,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.assistant"),
href: "/manage/assistant",
icon: IconRobot,
hidden: !serverSettings.featureControls.assistantEnabled || !session?.user.permissions.includes("admin"),
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.settings"),
@@ -46,6 +46,7 @@ export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
<TextInput label={t("appName.label")} {...form.getInputProps("branding.appName")} />
<IconPicker
label={t("logo.label")}
description={null}
placeholder={t("logo.placeholder")}
withAsterisk={false}
value={logoInput.value ?? ""}
@@ -54,6 +55,7 @@ export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
/>
<IconPicker
label={t("favicon.label")}
description={null}
placeholder={t("favicon.placeholder")}
withAsterisk={false}
value={faviconInput.value ?? ""}
@@ -108,6 +110,7 @@ export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
<TextInput label={t("greeting.label")} {...form.getInputProps("branding.greeting")} />
<IconPicker
label={t("signInBackground.label")}
description={null}
placeholder={t("signInBackground.placeholder")}
withAsterisk={false}
value={backgroundInput.value ?? ""}
@@ -142,7 +145,7 @@ export const BrandingSettingsForm = ({ form }: BrandingSettingsFormProps) => {
<Text size="sm" c="dimmed">
{t("authVisibility.description")}
</Text>
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="xs" verticalSpacing="xs">
<SimpleGrid cols={3} spacing="xs">
<Switch
size="sm"
label={t("authVisibility.appName")}
@@ -1,39 +0,0 @@
"use client";
import { SimpleGrid, Switch } from "@mantine/core";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
interface FeatureControlsSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const FeatureControlsSettingsForm = ({ form }: FeatureControlsSettingsFormProps) => {
const t = useI18n("management.page.settings.section.featureControls");
return (
<SectionCard title={t("title")}>
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md" verticalSpacing="sm">
<Switch
label={t("assistant.label")}
description={t("assistant.description")}
{...form.getInputProps("featureControls.assistantEnabled", { type: "checkbox" })}
/>
<Switch
label={t("boardSwitcher.label")}
description={t("boardSwitcher.description")}
{...form.getInputProps("featureControls.boardSwitcherEnabled", { type: "checkbox" })}
/>
<Switch
label={t("widgetContextMenu.label")}
description={t("widgetContextMenu.description")}
{...form.getInputProps("featureControls.widgetContextMenuEnabled", { type: "checkbox" })}
/>
</SimpleGrid>
</SectionCard>
);
};
@@ -12,7 +12,7 @@ import { colorSchemes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { brandingServerSettingsSchema, featureControlsServerSettingsSchema } from "@homarr/server-settings";
import { brandingServerSettingsSchema } from "@homarr/server-settings";
import { useI18n } from "@homarr/translation/client";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
@@ -22,7 +22,6 @@ import { BoardSettingsForm } from "./board-settings-form";
import { BrandingSettingsForm } from "./branding-settings-form";
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
import { CultureSettingsForm } from "./culture-settings-form";
import { FeatureControlsSettingsForm } from "./feature-controls-settings-form";
import { SearchSettingsForm } from "./search-settings-form";
import { UserSettingsForm } from "./user-settings-form";
@@ -41,7 +40,6 @@ const settingsFormSchema = z.object({
defaultColorScheme: z.enum(colorSchemes),
defaultLocale: z.string(),
branding: brandingServerSettingsSchema,
featureControls: featureControlsServerSettingsSchema,
});
export type FormValues = z.infer<typeof settingsFormSchema>;
@@ -61,7 +59,6 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
defaultColorScheme: initialData.appearance.defaultColorScheme,
defaultLocale: initialData.culture.defaultLocale,
branding: initialData.branding,
featureControls: initialData.featureControls,
});
interface SettingsFormProps {
@@ -149,11 +146,6 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
when: changed("branding"),
value: values.branding,
},
{
settingsKey: "featureControls",
when: changed("featureControls"),
value: values.featureControls,
},
];
const promises = groups
@@ -194,7 +186,6 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
<AppearanceSettingsForm form={form} />
<BrandingSettingsForm form={form} />
<FeatureControlsSettingsForm form={form} />
<CultureSettingsForm form={form} />
<AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} />
@@ -65,8 +65,7 @@
}
.sortableStrip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
display: flex;
gap: var(--mantine-spacing-sm);
width: 100%;
min-width: 100%;
@@ -79,7 +78,8 @@
display: grid;
grid-template-rows: 1.25rem 2.5rem;
gap: 0.3rem;
min-width: 0;
min-width: max-content;
flex: 1 0 0;
align-content: center;
}
@@ -90,8 +90,8 @@
min-width: 0;
min-height: 1.25rem;
padding-inline: 0.15rem;
border-radius: var(--mantine-radius-sm);
transition: background-color 120ms ease;
pointer-events: none;
user-select: none;
}
.zoneDelimiter :global(.mantine-Divider-label) {
@@ -103,14 +103,6 @@
text-transform: uppercase;
}
.zoneDelimiter[data-over="true"] {
background: var(--mantine-primary-color-light);
}
.zoneDelimiter[data-over="true"] :global(.mantine-Divider-label) {
color: var(--mantine-primary-color-filled);
}
.zoneControls {
display: flex;
align-items: center;
@@ -118,13 +110,7 @@
min-width: 0;
height: 2.5rem;
padding: 0.125rem;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.zoneControls::-webkit-scrollbar {
display: none;
overflow: visible;
}
.zoneSegment[data-zone="center"] .zoneControls {
@@ -135,31 +121,31 @@
justify-content: safe flex-end;
}
.zoneTail {
.emptyZoneTarget {
display: grid;
place-items: center;
width: 1.5rem;
min-width: 2.25rem;
height: 2.25rem;
flex: 0 0 1.5rem;
flex: 1 0 2.25rem;
border-radius: var(--mantine-radius-sm);
transition: background-color 120ms ease;
}
.zoneTailMarker {
.emptyZoneTarget span {
width: 2px;
height: 1rem;
border-radius: var(--mantine-radius-xl);
background: var(--mantine-color-default-border);
background: transparent;
transition:
height 120ms ease,
background-color 120ms ease;
}
.zoneTail[data-over="true"] {
.emptyZoneTarget[data-over="true"] {
background: var(--mantine-primary-color-light);
}
.zoneTail[data-over="true"] .zoneTailMarker {
.emptyZoneTarget[data-over="true"] span {
height: 1.5rem;
background: var(--mantine-primary-color-filled);
}
@@ -257,23 +243,6 @@
display: grid;
}
.contextActions {
display: flex;
gap: 5px;
align-items: center;
height: 2.25rem;
flex: 0 0 auto;
padding-inline: 0.35rem;
}
.contextActions span {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--mantine-color-dimmed);
opacity: 0.45;
}
.hiddenPreview {
position: absolute;
inset: 0 0 auto;
@@ -333,9 +302,8 @@
@media (prefers-reduced-motion: reduce) {
.device,
.zoneDelimiter,
.zoneTail,
.zoneTailMarker,
.emptyZoneTarget,
.emptyZoneTarget span,
.itemButton {
transition: none;
}
@@ -15,7 +15,6 @@ import {
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
@@ -44,6 +43,7 @@ import {
IconEyeOff,
IconHome,
IconLayoutDashboard,
IconPencil,
IconPlus,
IconReplace,
IconRobot,
@@ -55,7 +55,6 @@ import {
} from "@tabler/icons-react";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type {
HeaderBuiltinItemId,
@@ -73,6 +72,7 @@ import {
getHeaderItemZone,
headerBuiltinItemIds,
headerZoneIds,
isRequiredHeaderItem,
} from "@homarr/validation/user";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
@@ -94,7 +94,6 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
const [previewSize, setPreviewSize] = useState<PreviewSize>("desktop");
const [draggedItemKey, setDraggedItemKey] = useState<string | null>(null);
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const { featureControls } = useSettings();
const t = useI18n("management.page.user.setting.general.header");
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
@@ -106,12 +105,11 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
);
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
const activeItems = getHeaderItems(value.zones);
const visibleItems = activeItems.filter((item) => isHeaderItemAvailable(item, featureControls));
const visibleItemKeys = visibleItems.map(getHeaderItemKey);
const visibleItemKeys = activeItems.map(getHeaderItemKey);
const activeItemKeySet = new Set(activeItems.map(getHeaderItemKey));
const inactiveBuiltinIds = headerBuiltinItemIds.filter((itemId) => {
const item = createBuiltinHeaderItem(itemId);
return isHeaderItemAvailable(item, featureControls) && !activeItemKeySet.has(getHeaderItemKey(item));
return !isRequiredHeaderItem(item) && !activeItemKeySet.has(getHeaderItemKey(item));
});
const availableBoards = boards.filter(
(board) => !activeItemKeySet.has(getHeaderItemKey(createBoardHeaderItem(board.id))),
@@ -130,7 +128,7 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
const updateZones = (zones: HeaderZones) => onChange({ ...value, zones });
const removeItem = (item: HeaderItem) => {
if (item.type === "builtin" && item.id === "user") return;
if (isRequiredHeaderItem(item)) return;
const zone = getHeaderItemZone(value.zones, item);
if (!zone) return;
const itemKey = getHeaderItemKey(item);
@@ -159,10 +157,14 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
if (!sourceZone || !targetZone) return;
const targetItem = findHeaderItem(activeItems, over.id);
let targetIndex = 0;
if (getDropZoneTailId(targetZone) === over.id) targetIndex = value.zones[targetZone].length;
else if (targetItem)
let targetIndex = value.zones[targetZone].length;
if (targetItem) {
targetIndex = value.zones[targetZone].findIndex((candidate) => isSameItem(candidate, targetItem));
const translatedRect = active.rect.current.translated;
const activeCenter = translatedRect ? translatedRect.left + translatedRect.width / 2 : null;
const targetCenter = over.rect.left + over.rect.width / 2;
if (activeCenter !== null && activeCenter > targetCenter) targetIndex += 1;
}
if (targetIndex < 0) return;
updateZones(moveHeaderItem(value.zones, item, targetZone, targetIndex));
};
@@ -227,10 +229,9 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
key={zone}
zone={zone}
label={t(`zones.${zone}` as never)}
endLabel={t("action.dropAtEnd", { zone: t(`zones.${zone}` as never) })}
emptyLabel={t("action.dropIntoZone", { zone: t(`zones.${zone}` as never) })}
leading={zone === "left" ? <MobileBurger /> : null}
trailing={zone === "right" ? <ContextActions /> : null}
items={value.zones[zone].filter((item) => isHeaderItemAvailable(item, featureControls))}
items={value.zones[zone]}
previewSize={previewSize}
searchDisplay={value.searchDisplay}
selectedItemKey={selectedItemKey}
@@ -302,7 +303,7 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
]}
/>
) : null}
{selectedItem.type !== "builtin" || selectedItem.id !== "user" ? (
{!isRequiredHeaderItem(selectedItem) ? (
<Button
size="xs"
variant="subtle"
@@ -314,7 +315,7 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
</Button>
) : (
<Text c="dimmed" size="xs">
{t("available.accountAlways")}
{t("available.requiredAlways")}
</Text>
)}
</Group>
@@ -393,9 +394,8 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId, mobileHom
interface ZoneSegmentProps {
zone: HeaderZoneId;
label: string;
endLabel: string;
emptyLabel: string;
leading: ReactNode;
trailing: ReactNode;
items: HeaderItem[];
previewSize: PreviewSize;
searchDisplay: HeaderPreferences["searchDisplay"];
@@ -409,9 +409,8 @@ interface ZoneSegmentProps {
const ZoneSegment = ({
zone,
label,
endLabel,
emptyLabel,
leading,
trailing,
items,
previewSize,
searchDisplay,
@@ -441,29 +440,25 @@ const ZoneSegment = ({
/>
);
})}
<ZoneTail zone={zone} label={endLabel} />
{trailing}
{items.length === 0 ? <EmptyZoneDropTarget zone={zone} label={emptyLabel} /> : null}
</div>
</section>
);
const ZoneDelimiter = ({ zone, label }: { zone: HeaderZoneId; label: string }) => {
// Fixed reference point inside the shared sortable row; it never enters SortableContext.
const ZoneDelimiter = ({ zone, label }: { zone: HeaderZoneId; label: string }) => (
<div className={classes.zoneDelimiter} data-zone={zone}>
<Divider label={label} labelPosition="center" />
</div>
);
const EmptyZoneDropTarget = ({ zone, label }: { zone: HeaderZoneId; label: string }) => {
const { isOver, setNodeRef } = useDroppable({ id: getDropZoneId(zone) });
return (
<div ref={setNodeRef} className={classes.zoneDelimiter} data-zone={zone} data-over={isOver || undefined}>
<Divider label={label} labelPosition="center" />
</div>
);
};
const ZoneTail = ({ zone, label }: { zone: HeaderZoneId; label: string }) => {
const { isOver, setNodeRef } = useDroppable({ id: getDropZoneTailId(zone) });
return (
<div ref={setNodeRef} className={classes.zoneTail} data-over={isOver || undefined}>
<div ref={setNodeRef} className={classes.emptyZoneTarget} data-over={isOver || undefined}>
<VisuallyHidden>{label}</VisuallyHidden>
<span className={classes.zoneTailMarker} aria-hidden />
<span aria-hidden />
</div>
);
};
@@ -476,13 +471,6 @@ const MobileBurger = () => (
</div>
);
const ContextActions = () => (
<div className={classes.contextActions} aria-hidden>
<span />
<span />
</div>
);
interface SortableHeaderItemProps {
item: HeaderItem;
label: string;
@@ -516,32 +504,30 @@ const SortableHeaderItem = ({
return (
<div ref={setNodeRef} style={style} className={classes.sortableItem} data-selected={selected || undefined}>
<Tooltip label={configureLabel} openDelay={500}>
<button
ref={setActivatorNodeRef}
type="button"
className={classes.itemButton}
data-kind={item.type === "builtin" ? item.id : "board"}
data-compact={previewSize === "mobile" || undefined}
data-wide={
item.type === "builtin" && item.id === "search" && searchDisplay === "input" && previewSize === "desktop"
? true
: undefined
}
aria-label={configureLabel}
onClick={() => onSelect(itemKey)}
{...attributes}
{...listeners}
>
<HeaderItemPreview
item={item}
label={label}
compact={previewSize === "mobile"}
searchDisplay={searchDisplay}
boardLogo={boardLogo}
/>
</button>
</Tooltip>
<button
ref={setActivatorNodeRef}
type="button"
className={classes.itemButton}
data-kind={item.type === "builtin" ? item.id : "board"}
data-compact={previewSize === "mobile" || undefined}
data-wide={
item.type === "builtin" && item.id === "search" && searchDisplay === "input" && previewSize === "desktop"
? true
: undefined
}
aria-label={configureLabel}
onClick={() => onSelect(itemKey)}
{...attributes}
{...listeners}
>
<HeaderItemPreview
item={item}
label={label}
compact={previewSize === "mobile"}
searchDisplay={searchDisplay}
boardLogo={boardLogo}
/>
</button>
</div>
);
};
@@ -628,27 +614,25 @@ const moveHeaderItem = (
const sourceIndex = sourceItems.findIndex((candidate) => isSameItem(candidate, item));
if (sourceIndex < 0) return zones;
if (sourceZone === targetZone) {
const normalizedTargetIndex = Math.min(targetIndex, sourceItems.length - 1);
if (sourceIndex === normalizedTargetIndex) return zones;
return { ...zones, [sourceZone]: arrayMove(sourceItems, sourceIndex, normalizedTargetIndex) };
}
const sourceItemsWithoutMovedItem = sourceItems.filter((candidate) => !isSameItem(candidate, item));
const targetItems = sourceZone === targetZone ? sourceItemsWithoutMovedItem : zones[targetZone];
let normalizedTargetIndex = Math.min(Math.max(targetIndex, 0), targetItems.length);
if (sourceZone === targetZone && sourceIndex < targetIndex) normalizedTargetIndex -= 1;
if (sourceZone === targetZone && sourceIndex === normalizedTargetIndex) return zones;
const targetItems = zones[targetZone];
return {
...zones,
[sourceZone]: sourceItems.filter((candidate) => !isSameItem(candidate, item)),
[targetZone]: [...targetItems.slice(0, targetIndex), item, ...targetItems.slice(targetIndex)],
[sourceZone]: sourceItemsWithoutMovedItem,
[targetZone]: [...targetItems.slice(0, normalizedTargetIndex), item, ...targetItems.slice(normalizedTargetIndex)],
};
};
const getDropZoneId = (zone: HeaderZoneId) => `header-zone-${zone}`;
const getDropZoneTailId = (zone: HeaderZoneId) => `header-zone-${zone}-end`;
const getDropZone = (zones: HeaderZones, id: UniqueIdentifier): HeaderZoneId | undefined => {
const item = findHeaderItem(getHeaderItems(zones), id);
if (item) return getHeaderItemZone(zones, item);
return headerZoneIds.find((zone) => getDropZoneId(zone) === id || getDropZoneTailId(zone) === id);
return headerZoneIds.find((zone) => getDropZoneId(zone) === id);
};
const findHeaderItem = (items: HeaderItem[], id: UniqueIdentifier | null): HeaderItem | undefined => {
@@ -659,22 +643,14 @@ const findHeaderItem = (items: HeaderItem[], id: UniqueIdentifier | null): Heade
const isSameItem = (first: HeaderItem, second: HeaderItem) => getHeaderItemKey(first) === getHeaderItemKey(second);
const isHeaderItemAvailable = (
item: HeaderItem,
featureControls: { assistantEnabled: boolean; boardSwitcherEnabled: boolean },
) => {
if (item.type === "board") return true;
if (item.id === "assistant") return featureControls.assistantEnabled;
if (item.id === "boardSwitcher") return featureControls.boardSwitcherEnabled;
return true;
};
const getBuiltinItemIcon = (itemId: HeaderBuiltinItemId) => {
if (itemId === "search") return IconSearch;
if (itemId === "home") return IconHome;
if (itemId === "boardSwitcher") return IconReplace;
if (itemId === "assistant") return IconRobot;
if (itemId === "docker") return IconBrandDocker;
if (itemId === "boardEdit") return IconPencil;
if (itemId === "boardSettings") return IconSettings;
if (itemId === "settings") return IconSettings;
if (itemId === "themeToggle") return IconSunMoon;
if (itemId === "user") return IconUserCircle;
@@ -18,7 +18,6 @@ import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq } from "@homarr/db";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { assistantConfigurations, assistantThreads } from "@homarr/db/schema";
import {
assistantProviderCanUseOpenRouterServerTools,
@@ -374,11 +373,6 @@ export async function POST(request: Request) {
return Response.json({ error: "Sign in to use Assistant." }, { status: 401 });
}
const featureControls = await getServerSettingByKeyAsync(db, "featureControls");
if (!featureControls.assistantEnabled) {
return Response.json({ error: "Assistant is disabled." }, { status: 404 });
}
const requestBody = await request.text();
if (requestBody.length > assistantRequestMaxBytes) {
return Response.json({ error: "The assistant request is too large." }, { status: 413 });
@@ -23,16 +23,8 @@ const EnabledAssistantRoot = dynamic(() =>
import("./assistant-provider").then((module) => ({ default: module.EnabledAssistantRoot })),
);
const AssistantSpotlightPlaceholder = ({ description }: { description: string }) => {
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
useRegisterAssistantSpotlightPlaceholder(description);
return null;
};
const DisabledAssistant = ({
children,
description,
discoverable = true,
}: PropsWithChildren<{ description: string; discoverable?: boolean }>) => {
const value = useMemo(
() => ({
enabled: false,
@@ -56,7 +48,6 @@ const DisabledAssistant = ({
return (
<AssistantContext.Provider value={value}>
{discoverable ? <AssistantSpotlightPlaceholder description={description} /> : null}
<AssistantWidgetRendererProvider renderer={AssistantBoardWidgetLazy}>{children}</AssistantWidgetRendererProvider>
</AssistantContext.Provider>
);
@@ -68,7 +59,7 @@ const DisabledAssistant = ({
* `unauthenticated` is kept apart from `unconfigured` because the server cannot check availability
* for a signed-out visitor, and telling them the instance is unconfigured would be a guess.
*/
export type AssistantAvailability = "enabled" | "unconfigured" | "unauthenticated" | "disabled" | "error";
export type AssistantAvailability = "enabled" | "unconfigured" | "unauthenticated" | "error";
interface AssistantGateProps extends PropsWithChildren {
availability: AssistantAvailability;
@@ -87,13 +78,5 @@ export const AssistantGate = ({ availability, children }: AssistantGateProps) =>
return <EnabledAssistantRoot>{children}</EnabledAssistantRoot>;
}
if (availability === "disabled") {
return (
<DisabledAssistant description={t("unavailable.disabledByServer")} discoverable={false}>
{children}
</DisabledAssistant>
);
}
return <DisabledAssistant description={t(unavailableMessageKeys[availability])}>{children}</DisabledAssistant>;
};
@@ -36,18 +36,16 @@ import classes from "./board-switcher.module.css";
export const boardSwitcherHotkey = "shift+c";
export interface BoardSwitcherControls {
enabled: boolean;
open: () => void;
preload: () => void;
hotkey: string;
}
interface BoardSwitcherProps {
enabled?: boolean;
children: (controls: BoardSwitcherControls) => ReactNode;
}
export const BoardSwitcher = ({ enabled = true, children }: BoardSwitcherProps) => {
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
const t = useI18n("board.action.switcher");
const manageBoardsT = useI18n("management.page.board");
const currentBoard = useOptionalBoard();
@@ -64,7 +62,7 @@ export const BoardSwitcher = ({ enabled = true, children }: BoardSwitcherProps)
} = clientApi.board.getManageOverview.useQuery(
{ fullPreview: true },
{
enabled: enabled && isOpen,
enabled: isOpen,
},
);
@@ -84,24 +82,16 @@ export const BoardSwitcher = ({ enabled = true, children }: BoardSwitcherProps)
const modalColumnCount = Math.max(1, Math.min(switcherBoards.length, responsiveColumnCount));
const openSwitcher = useCallback(() => {
if (!enabled) return;
setIsOpen(true);
}, [enabled]);
}, []);
const closeSwitcher = useCallback(() => {
setIsOpen(false);
setSearch("");
setActiveIndex(0);
}, []);
const preloadBoards = () => {
if (!enabled) return;
void utils.board.getManageOverview.prefetch({ fullPreview: true });
};
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
useHotkeys(enabled ? [[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]] : []);
useEffect(() => {
if (!enabled && isOpen) closeSwitcher();
}, [closeSwitcher, enabled, isOpen]);
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
useEffect(() => {
if (!isOpen || filteredBoards.length === 0) return;
@@ -186,10 +176,10 @@ export const BoardSwitcher = ({ enabled = true, children }: BoardSwitcherProps)
return (
<>
{children({ enabled, open: openSwitcher, preload: preloadBoards, hotkey: boardSwitcherHotkey })}
{children({ open: openSwitcher, preload: preloadBoards, hotkey: boardSwitcherHotkey })}
<Modal
opened={enabled && isOpen}
opened={isOpen}
onClose={closeSwitcher}
withCloseButton={false}
yOffset="15vh"
@@ -222,13 +222,7 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
if (
!session ||
!settings.featureControls.widgetContextMenuEnabled ||
!settings.enableRightClickOnWidgets ||
isEditMode ||
item.kind === "app"
) {
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
return <>{children}</>;
}
+11 -1
View File
@@ -10,10 +10,18 @@ import { HomarrLogo } from "./logo/homarr-logo";
interface Props {
logo?: ReactNode;
actions?: ReactNode;
boardEditAction?: ReactNode;
boardSettingsAction?: ReactNode;
hasNavigation?: boolean;
}
export const MainHeader = async ({ logo, actions, hasNavigation = true }: Props) => {
export const MainHeader = async ({
logo,
actions,
boardEditAction,
boardSettingsAction,
hasNavigation = true,
}: Props) => {
const session = await auth();
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
@@ -22,6 +30,8 @@ export const MainHeader = async ({ logo, actions, hasNavigation = true }: Props)
<ConfigurableHeader
logo={logo ?? <HomarrLogo size={appShellLogoHeight} />}
actions={actions}
boardEditAction={boardEditAction}
boardSettingsAction={boardSettingsAction}
hasNavigation={hasNavigation}
avatar={<CurrentUserAvatar size="md" />}
userId={session?.user.id ?? null}
@@ -41,10 +41,13 @@
.logo {
display: grid;
place-items: center;
min-width: 0;
min-height: 0;
width: auto;
height: calc(100% - 0.5rem);
height: calc(var(--app-shell-header-height) - 0.5rem);
max-height: calc(var(--app-shell-header-height) - 0.5rem);
max-width: min(12rem, 28vw);
flex: 0 0 auto;
flex: 0 1 auto;
overflow: hidden;
border-radius: var(--mantine-radius-md);
}
@@ -52,8 +55,9 @@
.logo :global(img.logo) {
display: block;
width: auto;
height: 100%;
height: auto;
max-width: 100%;
max-height: calc(var(--app-shell-header-height) - 0.5rem);
object-fit: contain;
}
@@ -47,6 +47,8 @@ import classes from "./configurable-header.module.css";
interface ConfigurableHeaderProps {
logo: ReactNode;
actions?: ReactNode;
boardEditAction?: ReactNode;
boardSettingsAction?: ReactNode;
hasNavigation: boolean;
avatar: ReactNode;
userId: string | null;
@@ -58,15 +60,15 @@ type HeaderBoard = RouterOutputs["board"]["getAllBoards"][number];
export const ConfigurableHeader = ({
logo,
actions,
boardEditAction,
boardSettingsAction,
hasNavigation,
avatar,
userId,
isAdmin,
isDockerEnabled,
}: ConfigurableHeaderProps) => {
const { featureControls, headerPreferences } = useSettings();
const assistantEnabled = featureControls.assistantEnabled;
const boardSwitcherEnabled = featureControls.boardSwitcherEnabled;
const { headerPreferences } = useSettings();
const assistant = useOptionalHomarrAssistant();
const { toggleColorScheme } = useMantineColorScheme();
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
@@ -80,7 +82,7 @@ export const ConfigurableHeader = ({
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
return (
<BoardSwitcher enabled={boardSwitcherEnabled}>
<BoardSwitcher>
{(boardSwitcher) => {
const renderItems = (items: HeaderItem[]) =>
items.map((item) => (
@@ -88,12 +90,12 @@ export const ConfigurableHeader = ({
key={getHeaderItemKey(item)}
item={item}
logo={logo}
boardEditAction={boardEditAction}
boardSettingsAction={boardSettingsAction}
avatar={avatar}
userId={userId}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
assistantEnabled={assistantEnabled}
boardSwitcherEnabled={boardSwitcherEnabled}
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
searchDisplay={headerPreferences.searchDisplay}
boardSwitcher={boardSwitcher}
@@ -151,12 +153,12 @@ export const ConfigurableHeader = ({
interface HeaderItemProps {
item: HeaderItem;
logo: ReactNode;
boardEditAction: ReactNode;
boardSettingsAction: ReactNode;
avatar: ReactNode;
userId: string | null;
isAdmin: boolean;
isDockerEnabled: boolean;
assistantEnabled: boolean;
boardSwitcherEnabled: boolean;
board: HeaderBoard | undefined;
searchDisplay: "input" | "icon";
boardSwitcher: BoardSwitcherControls;
@@ -170,12 +172,12 @@ interface HeaderItemProps {
const HeaderItem = ({
item,
logo,
boardEditAction,
boardSettingsAction,
avatar,
userId,
isAdmin,
isDockerEnabled,
assistantEnabled,
boardSwitcherEnabled,
board,
searchDisplay,
boardSwitcher,
@@ -214,6 +216,9 @@ const HeaderItem = ({
);
}
if (item.id === "boardEdit") return boardEditAction;
if (item.id === "boardSettings") return boardSettingsAction;
if (item.id === "search") {
return (
<TourTarget id="board-search">
@@ -242,7 +247,6 @@ const HeaderItem = ({
}
if (item.id === "boardSwitcher") {
if (!boardSwitcherEnabled) return null;
return (
<Tooltip label={label("boardSwitcher")}>
<HeaderButton
@@ -258,7 +262,7 @@ const HeaderItem = ({
}
if (item.id === "assistant") {
if (!assistantEnabled || !assistant?.enabled) return null;
if (!assistant?.enabled) return null;
return (
<Tooltip label={label("assistant")}>
<Indicator
@@ -67,17 +67,15 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
{boardSwitcher.enabled ? (
<Menu.Item
leftSection={<IconReplace size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
onClick={boardSwitcher.open}
onFocus={boardSwitcher.preload}
onPointerEnter={boardSwitcher.preload}
>
{tBoard("action.switch")}
</Menu.Item>
) : null}
<Menu.Item
leftSection={<IconReplace size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
onClick={boardSwitcher.open}
onFocus={boardSwitcher.preload}
onPointerEnter={boardSwitcher.preload}
>
{tBoard("action.switch")}
</Menu.Item>
<Menu.Divider />
<Menu.Item p={0} closeMenuOnClick={false} component="div">
+9 -19
View File
@@ -19,7 +19,6 @@ import {
items,
users,
} from "@homarr/db/schema";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
assistantProviderCanUseOpenRouterServerTools,
assistantProviderIds,
@@ -38,13 +37,6 @@ import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedPro
import { boardRouter, getHomeIdBoardAsync } from "./board";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const assistantEnabledProcedure = protectedProcedure.use(async ({ ctx, next }) => {
const featureControls = await getServerSettingByKeyAsync(ctx.db, "featureControls");
if (!featureControls.assistantEnabled) {
throw new TRPCError({ code: "NOT_FOUND", message: "Assistant is disabled." });
}
return next({ ctx });
});
const configurationId = "default";
const providerSchema = z.enum(assistantProviderIds);
@@ -461,8 +453,6 @@ const addFeedbackToMessageContent = (serializedContent: string, type: "positive"
export const assistantRouter = createTRPCRouter({
getAvailability: protectedProcedure.query(async ({ ctx }) => {
const featureControls = await getServerSettingByKeyAsync(ctx.db, "featureControls");
if (!featureControls.assistantEnabled) return { enabled: false };
if (isDemoMode) {
return { enabled: true };
}
@@ -475,11 +465,11 @@ export const assistantRouter = createTRPCRouter({
};
}),
getContextEntities: assistantEnabledProcedure.query(async ({ ctx }) => {
getContextEntities: protectedProcedure.query(async ({ ctx }) => {
return await getAssistantContextEntitiesAsync(ctx);
}),
getModelCapabilities: assistantEnabledProcedure
getModelCapabilities: protectedProcedure
.input(z.object({ modelId: z.string().trim().min(1).max(256).optional() }).optional())
.query(async ({ ctx, input }) => {
const configuration = await getConfigurationAsync(ctx.db);
@@ -496,7 +486,7 @@ export const assistantRouter = createTRPCRouter({
};
}),
getRuntimeOptions: assistantEnabledProcedure.query(async ({ ctx }) => {
getRuntimeOptions: protectedProcedure.query(async ({ ctx }) => {
const configuration = await getConfigurationAsync(ctx.db);
if (isDemoMode) {
return {
@@ -694,7 +684,7 @@ export const assistantRouter = createTRPCRouter({
});
}),
createThread: assistantEnabledProcedure
createThread: protectedProcedure
.input(z.object({ localId: z.string().max(128).optional() }).optional())
.mutation(async ({ ctx }) => {
const configuration = await getConfigurationAsync(ctx.db);
@@ -710,7 +700,7 @@ export const assistantRouter = createTRPCRouter({
return { id };
}),
updateThreadModel: assistantEnabledProcedure
updateThreadModel: protectedProcedure
.input(z.object({ threadId: z.string().max(64), modelId: z.string().trim().min(1).max(256) }))
.mutation(async ({ ctx, input }) => {
const [thread, configuration] = await Promise.all([
@@ -755,7 +745,7 @@ export const assistantRouter = createTRPCRouter({
};
}),
getGenerationTelemetry: assistantEnabledProcedure
getGenerationTelemetry: protectedProcedure
.input(
z.object({
threadId: z.string().max(64),
@@ -801,7 +791,7 @@ export const assistantRouter = createTRPCRouter({
};
}),
renameThread: assistantEnabledProcedure
renameThread: protectedProcedure
.input(z.object({ threadId: z.string().max(64), title: z.string().trim().min(1).max(120) }))
.mutation(async ({ ctx, input }) => {
await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
@@ -818,7 +808,7 @@ export const assistantRouter = createTRPCRouter({
await ctx.db.delete(assistantThreads).where(eq(assistantThreads.id, input.threadId));
}),
appendMessage: assistantEnabledProcedure
appendMessage: protectedProcedure
.input(
z.object({
threadId: z.string().max(64),
@@ -879,7 +869,7 @@ export const assistantRouter = createTRPCRouter({
.where(eq(assistantThreads.id, thread.id));
}),
submitFeedback: assistantEnabledProcedure
submitFeedback: protectedProcedure
.input(
z.object({
threadId: z.string().max(64),
-12
View File
@@ -14,9 +14,7 @@ import {
authBrandingSchema,
brandingServerSettingsSchema,
defaultServerSettingsKeys,
featureControlsServerSettingsSchema,
parseBrandingSettings,
parseFeatureControls,
} from "@homarr/server-settings";
import { createTRPCRouter, permissionRequiredProcedure, publicProcedure } from "../trpc";
@@ -37,8 +35,6 @@ const legacyAuthBrandingUpdateSchema = z.object({
showCustomLogoOnLogin: z.boolean().optional(),
showCustomGreetingOnLogin: z.boolean().optional(),
});
const featureControlsServerSettingsUpdateSchema = featureControlsServerSettingsSchema.partial();
export const serverSettingsRouter = createTRPCRouter({
getCulture: publicProcedure.query(async ({ ctx }) => {
return await getServerSettingByKeyAsync(ctx.db, "culture");
@@ -137,14 +133,6 @@ export const serverSettingsRouter = createTRPCRouter({
await updateServerSettingByKeyAsync(ctx.db, "branding", value);
return;
}
if (input.settingsKey === "featureControls") {
const current = await getServerSettingByKeyAsync(ctx.db, "featureControls");
const parsedInput = featureControlsServerSettingsUpdateSchema.parse(input.value);
const value = parseFeatureControls({ ...current, ...parsedInput });
await updateServerSettingByKeyAsync(ctx.db, "featureControls", value);
return;
}
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
...current,
@@ -1 +1 @@
ALTER TABLE `user` ADD `header_preferences` text DEFAULT ('{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"}]}}') NOT NULL;
ALTER TABLE `user` ADD `header_preferences` text DEFAULT ('{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"},{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"}]}}') NOT NULL;
@@ -1,2 +0,0 @@
INSERT IGNORE INTO `serverSetting` (`setting_key`, `value`)
VALUES ('featureControls', '{"json":{"assistantEnabled":true,"boardSwitcherEnabled":true,"widgetContextMenuEnabled":true}}');
@@ -2656,7 +2656,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"}]}}')"
"default": "('{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"},{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"}]}}')"
},
"completed_manage_tour": {
"name": "completed_manage_tour",
@@ -2656,7 +2656,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"}]}}')"
"default": "('{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"},{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"}]}}')"
},
"completed_manage_tour": {
"name": "completed_manage_tour",
File diff suppressed because it is too large. Load diff
@@ -351,13 +351,6 @@
"when": 1787496053718,
"tag": "0049_add_server_branding_settings",
"breakpoints": true
},
{
"idx": 50,
"version": "5",
"when": 1787496060001,
"tag": "0050_add_feature_controls_server_setting",
"breakpoints": true
}
]
}
@@ -1 +1 @@
ALTER TABLE "user" ADD COLUMN "header_preferences" text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"}]}}' NOT NULL;
ALTER TABLE "user" ADD COLUMN "header_preferences" text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"},{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"}]}}' NOT NULL;
@@ -1,3 +0,0 @@
INSERT INTO "serverSetting" ("setting_key", "value")
VALUES ('featureControls', '{"json":{"assistantEnabled":true,"boardSwitcherEnabled":true,"widgetContextMenuEnabled":true}}')
ON CONFLICT ("setting_key") DO NOTHING;
@@ -2521,7 +2521,7 @@
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"},{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"}]}}'"
},
"completed_manage_tour": {
"name": "completed_manage_tour",
@@ -2521,7 +2521,7 @@
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"},{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"}]}}'"
},
"completed_manage_tour": {
"name": "completed_manage_tour",
File diff suppressed because it is too large. Load diff
@@ -113,13 +113,6 @@
"when": 1787496053659,
"tag": "0015_add_server_branding_settings",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1787496060002,
"tag": "0016_add_feature_controls_server_setting",
"breakpoints": true
}
]
}
@@ -1 +1 @@
ALTER TABLE `user` ADD `header_preferences` text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"}]}}' NOT NULL;
ALTER TABLE `user` ADD `header_preferences` text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"user"},{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"}]}}' NOT NULL;
@@ -1,3 +0,0 @@
INSERT INTO `serverSetting` (`setting_key`, `value`)
VALUES ('featureControls', '{"json":{"assistantEnabled":true,"boardSwitcherEnabled":true,"widgetContextMenuEnabled":true}}')
ON CONFLICT (`setting_key`) DO NOTHING;
@@ -2551,7 +2551,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"},{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"}]}}'"
},
"completed_manage_tour": {
"name": "completed_manage_tour",
@@ -2551,7 +2551,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"user\"},{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"}]}}'"
},
"completed_manage_tour": {
"name": "completed_manage_tour",
File diff suppressed because it is too large. Load diff
@@ -337,13 +337,6 @@
"when": 1787496053654,
"tag": "0047_add_server_branding_settings",
"breakpoints": true
},
{
"idx": 48,
"version": "6",
"when": 1787496060000,
"tag": "0048_add_feature_controls_server_setting",
"breakpoints": true
}
]
}
+1 -15
View File
@@ -1,12 +1,7 @@
import SuperJSON from "superjson";
import type { ServerSettings } from "@homarr/server-settings";
import {
defaultServerSettings,
defaultServerSettingsKeys,
parseBrandingSettings,
parseFeatureControls,
} from "@homarr/server-settings";
import { defaultServerSettings, defaultServerSettingsKeys, parseBrandingSettings } from "@homarr/server-settings";
import type { Database } from "..";
import { eq } from "..";
@@ -28,11 +23,6 @@ export const getServerSettingsAsync = async (db: Database) => {
acc[settingKey] = parseBrandingSettings(parsedSetting) as never;
return acc;
}
if (settingKey === "featureControls") {
acc[settingKey] = parseFeatureControls(parsedSetting) as never;
return acc;
}
acc[settingKey] = {
...defaultServerSettings[settingKey],
...parsedSetting,
@@ -54,10 +44,6 @@ export const getServerSettingByKeyAsync = async <TKey extends keyof ServerSettin
if (key === "branding") {
return parseBrandingSettings(parsedSetting) as ServerSettings[TKey];
}
if (key === "featureControls") {
return parseFeatureControls(parsedSetting) as ServerSettings[TKey];
}
return {
...defaultServerSettings[key],
...parsedSetting,
@@ -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)" },
-25
View File
@@ -9,7 +9,6 @@ export const defaultServerSettingsKeys = [
"user",
"appearance",
"branding",
"featureControls",
"culture",
"search",
] as const;
@@ -25,13 +24,6 @@ export const authBrandingSchema = z.object({
showGreeting: z.boolean(),
});
export const featureControlsServerSettingsSchema = z.object({
assistantEnabled: z.boolean(),
boardSwitcherEnabled: z.boolean(),
widgetContextMenuEnabled: z.boolean(),
});
export type FeatureControlsSettings = z.infer<typeof featureControlsServerSettingsSchema>;
const brandingImageUrlSchema = z
.string()
.trim()
@@ -81,12 +73,6 @@ export const defaultBrandingSettings: BrandingSettings = {
defaultRadius: "md",
};
export const defaultFeatureControls: FeatureControlsSettings = {
assistantEnabled: true,
boardSwitcherEnabled: true,
widgetContextMenuEnabled: true,
};
const legacyLoginBrandingSchema = z.object({
showCustomAppNameOnLogin: z.boolean().optional(),
showCustomLogoOnLogin: z.boolean().optional(),
@@ -122,16 +108,6 @@ export const parseBrandingSettings = (value: unknown): BrandingSettings => {
return result.data;
};
export const parseFeatureControls = (value: unknown) => {
const partialValue = z.record(z.string(), z.unknown()).safeParse(value);
if (!partialValue.success) return defaultFeatureControls;
const result = featureControlsServerSettingsSchema.safeParse({ ...defaultFeatureControls, ...partialValue.data });
if (!result.success) return defaultFeatureControls;
return result.data;
};
export const defaultServerSettings = {
analytics: {
enableGeneral: true,
@@ -156,7 +132,6 @@ export const defaultServerSettings = {
defaultColorScheme: "auto" as ColorScheme,
},
branding: defaultBrandingSettings,
featureControls: defaultFeatureControls,
culture: {
defaultLocale: "en" as SupportedLanguage,
},
-3
View File
@@ -24,7 +24,6 @@ export type SettingsContextProps = Omit<
> &
Pick<ServerSettings["user"], "enableGravatar"> & {
branding: ServerSettings["branding"];
featureControls: ServerSettings["featureControls"];
};
export interface PublicServerSettings {
@@ -35,7 +34,6 @@ export interface PublicServerSettings {
>;
user: Pick<ServerSettings["user"], "enableGravatar">;
branding: ServerSettings["branding"];
featureControls: ServerSettings["featureControls"];
}
export type UserSettings = Pick<
@@ -71,5 +69,4 @@ export const createSettings = ({
forceDisableStatus: serverSettings.board.forceDisableStatus,
enableGravatar: serverSettings.user.enableGravatar,
branding: parseBrandingSettings(serverSettings.branding),
featureControls: serverSettings.featureControls,
});
+5 -19
View File
@@ -5689,7 +5689,7 @@
"header": {
"title": "Header studio",
"badge": "Personal",
"description": "Drag each control across the fixed Left, Center, and Right dividers. Click a control to configure it.",
"description": "This is one continuous row. Drag controls anywhere; Left, Center, and Right are fixed dividers and never move.",
"device": {
"desktop": "Desktop",
"mobile": "Mobile"
@@ -5704,7 +5704,7 @@
"show": "Show the header",
"configure": "Move or configure {item}",
"remove": "Remove {item}",
"dropAtEnd": "Drop at the end of the {zone} zone"
"dropIntoZone": "Move into the {zone} section"
},
"preview": {
"board": "Dashboard preview for {name}",
@@ -5713,7 +5713,7 @@
"available": {
"title": "Available controls",
"description": "Add a control, then drag its full button into position above.",
"accountAlways": "Account access always remains available",
"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",
@@ -5733,6 +5733,8 @@
"boardSwitcher": "Boards",
"assistant": "Assistant",
"docker": "Docker",
"boardEdit": "Edit board",
"boardSettings": "Board settings",
"settings": "Your settings",
"themeToggle": "Toggle theme",
"user": "Account",
@@ -6011,21 +6013,6 @@
"off": "Off"
}
},
"featureControls": {
"title": "Feature controls",
"assistant": {
"label": "Assistant",
"description": "Show and allow the instance Assistant."
},
"boardSwitcher": {
"label": "Board switcher",
"description": "Allow board switching from the header, account menu, and keyboard shortcut."
},
"widgetContextMenu": {
"label": "Widget context menu",
"description": "Allow widget actions from the context menu."
}
},
"culture": {
"title": "Culture",
"defaultLocale": {
@@ -7625,7 +7612,6 @@
},
"unavailable": {
"checking": "Checking whether the assistant is available…",
"disabledByServer": "An administrator has disabled Assistant for this server.",
"error": "The assistant's availability could not be checked. Try again.",
"notConfigured": "An administrator must configure and enable the assistant first.",
"signIn": "Sign in to use Assistant."
+33 -5
View File
@@ -9,11 +9,15 @@ export const headerBuiltinItemIds = [
"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;
@@ -52,6 +56,9 @@ export const createBuiltinHeaderItem = (id: HeaderBuiltinItemId): HeaderBuiltinI
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}`;
@@ -165,6 +172,22 @@ const migrateLegacyHeaderPreferencesV1 = (
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,
@@ -172,9 +195,9 @@ export const headerPreferencesMutationSchema = z
legacyHeaderPreferencesV2Schema,
])
.transform((preferences): HeaderPreferences => {
if (preferences.version === 1) return migrateLegacyHeaderPreferencesV1(preferences);
if (preferences.version === 2) return migrateLegacyHeaderPreferencesV2(preferences);
return preferences;
if (preferences.version === 1) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV1(preferences));
if (preferences.version === 2) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV2(preferences));
return includeRequiredHeaderItems(preferences);
});
export const defaultHeaderPreferences = {
@@ -184,7 +207,11 @@ export const defaultHeaderPreferences = {
zones: {
left: [createBuiltinHeaderItem("logo")],
center: [createBuiltinHeaderItem("search")],
right: [createBuiltinHeaderItem("user")],
right: [
createBuiltinHeaderItem("user"),
createBuiltinHeaderItem("boardEdit"),
createBuiltinHeaderItem("boardSettings"),
],
},
} satisfies HeaderPreferences;
@@ -201,4 +228,5 @@ export const parseHeaderPreferences = (value: unknown): HeaderPreferences => {
}
};
export const serializeHeaderPreferences = (value: HeaderPreferences) => JSON.stringify(value);
export const serializeHeaderPreferences = (value: HeaderPreferences) =>
JSON.stringify(includeRequiredHeaderItems(value));
+2
View File
@@ -25,7 +25,9 @@ export {
headerPreferencesVersion,
headerSearchDisplayValues,
headerZoneIds,
isRequiredHeaderItem,
parseHeaderPreferences,
requiredHeaderBuiltinItemIds,
} from "./header-preferences";
export type {
HeaderBoardItem,