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">