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

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

No files matched your search

@@ -220,7 +220,7 @@ const renderMessage = (message: AssistantConversationExportMessage, index: numbe
export const buildAssistantMessageMarkdown = (message: AssistantConversationExportMessage, exportedAt = new Date()) => {
const title = `${roleLabel(getMessagePayload(message)?.role)} ${message.id}`;
return `# ${title}\n\n> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
return `# ${title}\n\n> Assistant debugging export. Secret-looking fields and embedded file data are redacted.\n\n- Exported: ${exportedAt.toISOString()}\n\n${renderMessage(message, 0)}\n`;
};
export const buildAssistantConversationMarkdown = ({
@@ -233,7 +233,7 @@ export const buildAssistantConversationMarkdown = ({
const header = [
`# ${title}`,
"",
"> Homarr Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
"> Assistant debugging export. Secret-looking fields and embedded file data are redacted.",
"",
`- Conversation ID: \`${thread.id}\``,
`- Model: ${thread.modelId ? `\`${thread.modelId}\`` : "Not recorded"}`,
@@ -25,7 +25,6 @@ const EnabledAssistantRoot = dynamic(() =>
const DisabledAssistant = ({ children, description }: PropsWithChildren<{ description: string }>) => {
useRegisterAssistantSpotlightPlaceholder(description);
const value = useMemo(
() => ({
enabled: false,
@@ -5,10 +5,10 @@
overflow: hidden;
color: var(--mantine-color-dimmed);
background:
radial-gradient(
circle at 15% 10%,
linear-gradient(
to top,
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
transparent 35%
transparent 55%
),
var(--mantine-color-body);
}
@@ -12,15 +12,22 @@ interface BoardLayoutThumbnailProps {
preview: BoardPreviewData | null;
label: string;
fitFullLayout?: boolean;
className?: string;
}
const maxThumbnailRows = 12;
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
export const BoardLayoutThumbnail = ({
preview,
label,
fitFullLayout = false,
className,
}: BoardLayoutThumbnailProps) => {
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
if (!preview || !layout) {
return (
<Center className={classes.canvas} role="img" aria-label={label}>
<Center className={canvasClassName} role="img" aria-label={label}>
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
</Center>
);
@@ -38,7 +45,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
});
if (lanes.length === 0) {
return (
<Center className={classes.canvas} role="img" aria-label={label}>
<Center className={canvasClassName} role="img" aria-label={label}>
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
</Center>
);
@@ -55,7 +62,7 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
};
return (
<Box className={classes.canvas} role="img" aria-label={label}>
<Box className={canvasClassName} role="img" aria-label={label}>
<div
className={classes.lanes}
style={{ gridTemplateColumns: lanes.map(({ columnCount }) => `${columnCount}fr`).join(" ") }}
@@ -36,8 +36,14 @@ import classes from "./board-switcher.module.css";
export const boardSwitcherHotkey = "shift+c";
export interface BoardSwitcherControls {
open: () => void;
preload: () => void;
hotkey: string;
}
interface BoardSwitcherProps {
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode;
children: (controls: BoardSwitcherControls) => ReactNode;
}
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
@@ -222,7 +222,9 @@ export const WidgetContextMenu = ({
[setItemOptions],
);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
return <>{children}</>;
}
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
@@ -24,6 +24,7 @@ export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }
name: innerProps.previousName,
});
void utils.board.getHomeBoard.invalidate();
void utils.board.getAllBoards.invalidate();
},
});
const form = useZodForm(boardRenameSchema.omit({ id: true }), {
@@ -9,8 +9,8 @@ const editorBoundaryPath = resolve(gridDirectory, "board-grid-editor-boundary.ts
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
describe("board grid import boundary", () => {
test("keeps every current dnd-kit runtime import inside the edit-only module", () => {
const runtimeImports = getSourceFiles(nextjsSourceDirectory)
test("keeps board grid dnd-kit runtime imports inside the edit-only module", () => {
const runtimeImports = getSourceFiles(gridDirectory)
.flatMap(getRuntimeImports)
.filter(({ specifier }) => isCurrentDndKitImport(specifier))
.map(({ filePath, kind, specifier }) => ({
@@ -1 +1,2 @@
export const appShellHeaderHeight = 60;
export const appShellLogoHeight = appShellHeaderHeight - 8;
+33 -37
View File
@@ -1,49 +1,45 @@
import type { ReactNode } from "react";
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { Link } from "@homarr/ui";
import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
import { LazySpotlight } from "./header/lazy-spotlight";
import { CurrentUserAvatar } from "~/components/user-avatar";
import { appShellLogoHeight } from "./constants";
import { ConfigurableHeader } from "./header/configurable-header";
import { HomarrLogo, HomarrLogoWithTitle } from "./logo/homarr-logo";
interface Props {
logo?: ReactNode;
logoWithTitle?: ReactNode;
actions?: ReactNode;
boardEditAction?: ReactNode;
boardSettingsAction?: ReactNode;
hasNavigation?: boolean;
}
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
export const MainHeader = async ({
logo,
logoWithTitle,
actions,
boardEditAction,
boardSettingsAction,
hasNavigation = true,
}: Props) => {
const session = await auth();
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
return (
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
style={{ overflowX: "hidden" }}
data-advanced-focus-background
data-app-shell-header
>
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <ClientBurger />}
<UnstyledButton component={Link} href="/">
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
</Group>
<TourTarget id="board-search">
<DesktopSearchInput />
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{actions}
<MobileSearchButton />
<TourTarget id="board-user-menu">
<UserButton />
</TourTarget>
</Group>
</Group>
<LazySpotlight />
</AppShellHeader>
<ConfigurableHeader
logo={logo ?? <HomarrLogo size={appShellLogoHeight} />}
logoWithTitle={logoWithTitle ?? <HomarrLogoWithTitle size="md" />}
actions={actions}
boardEditAction={boardEditAction}
boardSettingsAction={boardSettingsAction}
hasNavigation={hasNavigation}
avatar={<CurrentUserAvatar size="md" />}
userId={session?.user.id ?? null}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
/>
);
};
@@ -0,0 +1,142 @@
.header {
overflow: hidden;
}
.content {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center;
gap: var(--mantine-spacing-sm);
width: 100%;
height: 100%;
padding-inline: var(--mantine-spacing-md);
}
.zone {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.zone::-webkit-scrollbar {
display: none;
}
.zone[data-zone="left"] {
justify-content: flex-start;
}
.zone[data-zone="center"] {
justify-content: center;
}
.zone[data-zone="right"] {
justify-content: flex-end;
}
.search {
display: flex;
min-width: 0;
max-width: 100%;
}
.search[data-display="icon"] {
width: 2.25rem;
flex: 0 0 2.25rem;
}
.search > * {
max-width: 100%;
}
.zone [data-tour-target="board-search"] {
min-width: 0;
max-width: 100%;
}
.contextActions {
flex: 0 0 auto;
}
.floatingControls {
position: fixed;
z-index: var(--homarr-z-index-board-header);
top: max(rem(10px), env(safe-area-inset-top));
right: max(rem(10px), env(safe-area-inset-right));
padding: rem(8px);
border: rem(1px) solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--mantine-color-body) 58%, transparent);
box-shadow: 0 rem(12px) rem(36px) rgb(0 0 0 / 20%);
backdrop-filter: blur(rem(20px)) saturate(140%);
opacity: 0;
pointer-events: none;
transform: translateY(rem(-8px)) scale(0.98);
transition:
opacity 160ms ease,
transform 180ms ease;
}
.floatingControls[data-visible="true"],
.floatingControls:focus-within {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
}
.floatingControls button {
display: flex;
align-items: center;
justify-content: center;
}
.floatingControlsCorner {
position: fixed;
z-index: calc(var(--homarr-z-index-board-header) - 1);
top: 0;
right: 0;
width: rem(112px);
height: rem(112px);
pointer-events: none;
}
.floatingControlsCorner[data-active="true"] {
pointer-events: auto;
}
@media (max-width: $mantine-breakpoint-sm) {
.content {
gap: var(--mantine-spacing-xs);
padding-inline: var(--mantine-spacing-xs);
}
.zone {
gap: var(--mantine-spacing-xs);
}
.search {
width: auto;
flex: 0 0 auto;
}
}
@media (hover: none), (pointer: coarse) {
.floatingControls {
opacity: 1;
pointer-events: auto;
transform: none;
}
.floatingControlsCorner {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.floatingControls {
transition: none;
}
}
@@ -0,0 +1,418 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
AppShellHeader,
Avatar,
Group,
Indicator,
Loader,
Tooltip,
useMantineColorScheme,
} from "@mantine/core";
import {
IconBrandDocker,
IconHome,
IconLayoutDashboard,
IconReplace,
IconRobot,
IconSettings,
IconSunMoon,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { invariantTechnicalLabels } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { HeaderItem } from "@homarr/validation/user";
import { getHeaderItemKey, getHeaderItems } from "@homarr/validation/user";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
import { BoardSwitcher } from "~/components/board/board-switcher";
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
import { ClientBurger } from "./burger";
import { HeaderButton } from "./button";
import { DockerQuickAccessModal } from "./docker-quick-access-modal";
import { HeaderLogo } from "./header-logo";
import { LazySpotlight } from "./lazy-spotlight";
import { DesktopSearchInput, MobileSearchButton } from "./search";
import { TourTarget } from "./tour-target";
import { UserButtonClient } from "./user-client";
import classes from "./configurable-header.module.css";
interface ConfigurableHeaderProps {
logo: ReactNode;
logoWithTitle: ReactNode;
actions?: ReactNode;
boardEditAction?: ReactNode;
boardSettingsAction?: ReactNode;
hasNavigation: boolean;
avatar: ReactNode;
userId: string | null;
isAdmin: boolean;
isDockerEnabled: boolean;
}
type HeaderBoard = RouterOutputs["board"]["getAllBoards"][number];
const floatingControlsIntroDurationMs = 5_000;
const floatingControlsDismissDelayMs = 900;
export const ConfigurableHeader = ({
logo,
logoWithTitle,
actions,
boardEditAction,
boardSettingsAction,
hasNavigation,
avatar,
userId,
isAdmin,
isDockerEnabled,
}: ConfigurableHeaderProps) => {
const { headerPreferences } = useSettings();
const assistant = useOptionalHomarrAssistant();
const { toggleColorScheme } = useMantineColorScheme();
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const t = useI18n("management.page.user.setting.general.header");
const allHeaderItems = getHeaderItems(headerPreferences.zones);
const hasBoardShortcuts = allHeaderItems.some((item) => item.type === "board");
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(undefined, {
enabled: hasBoardShortcuts,
staleTime: 60_000,
});
const boardsById = useMemo(() => new Map(boards.map((board) => [board.id, board])), [boards]);
return (
<BoardSwitcher>
{(boardSwitcher) => {
const renderItems = (items: HeaderItem[]) =>
items.map((item) => (
<HeaderItem
key={getHeaderItemKey(item)}
item={item}
logo={logo}
logoWithTitle={logoWithTitle}
boardEditAction={boardEditAction}
boardSettingsAction={boardSettingsAction}
avatar={avatar}
userId={userId}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
searchDisplay={headerPreferences.searchDisplay}
logoDisplay={headerPreferences.logoDisplay}
boardSwitcher={boardSwitcher}
assistant={assistant}
openDockerModal={openDockerModal}
toggleColorScheme={toggleColorScheme}
label={(key) => t(`items.${key}` as never)}
boardUnavailableLabel={t("items.boardUnavailable")}
/>
));
return (
<>
{headerPreferences.visible ? (
<AppShellHeader
maw="100vw"
zIndex="var(--homarr-z-index-board-header)"
className={classes.header}
data-advanced-focus-background
data-app-shell-header
>
<div className={classes.content}>
<div className={classes.zone} data-zone="left">
{hasNavigation ? <ClientBurger /> : null}
{renderItems(headerPreferences.zones.left)}
</div>
<div className={classes.zone} data-zone="center">
{renderItems(headerPreferences.zones.center)}
</div>
<div className={classes.zone} data-zone="right">
{renderItems(headerPreferences.zones.right)}
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
</div>
</div>
</AppShellHeader>
) : (
<FloatingHeaderControls>
{hasNavigation ? <ClientBurger /> : null}
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</FloatingHeaderControls>
)}
<LazySpotlight />
</>
);
}}
</BoardSwitcher>
);
};
const FloatingHeaderControls = ({ children }: { children: ReactNode }) => {
const [isIntroComplete, setIsIntroComplete] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const isInteractingRef = useRef(false);
const dismissTimeoutRef = useRef<number | null>(null);
const clearDismissTimeout = () => {
if (dismissTimeoutRef.current === null) return;
window.clearTimeout(dismissTimeoutRef.current);
dismissTimeoutRef.current = null;
};
const dismissSoon = () => {
if (!isIntroComplete) return;
clearDismissTimeout();
dismissTimeoutRef.current = window.setTimeout(() => {
if (!isInteractingRef.current) setIsVisible(false);
}, floatingControlsDismissDelayMs);
};
const revealFromCorner = () => {
if (!isIntroComplete) return;
clearDismissTimeout();
setIsVisible(true);
};
const startInteraction = () => {
isInteractingRef.current = true;
clearDismissTimeout();
setIsVisible(true);
};
const endInteraction = () => {
isInteractingRef.current = false;
dismissSoon();
};
useEffect(() => {
const introTimeout = window.setTimeout(() => {
setIsIntroComplete(true);
if (!isInteractingRef.current) setIsVisible(false);
}, floatingControlsIntroDurationMs);
return () => {
window.clearTimeout(introTimeout);
if (dismissTimeoutRef.current !== null) window.clearTimeout(dismissTimeoutRef.current);
};
}, []);
return (
<>
<div
className={classes.floatingControlsCorner}
data-active={isIntroComplete || undefined}
aria-hidden
onPointerEnter={revealFromCorner}
onPointerMove={revealFromCorner}
onPointerDown={revealFromCorner}
onPointerLeave={dismissSoon}
/>
<Group
className={classes.floatingControls}
data-visible={isVisible || undefined}
gap={4}
wrap="nowrap"
onPointerEnter={startInteraction}
onPointerLeave={endInteraction}
onFocusCapture={startInteraction}
onBlurCapture={(event) => {
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
endInteraction();
}}
>
{children}
</Group>
</>
);
};
interface HeaderItemProps {
item: HeaderItem;
logo: ReactNode;
logoWithTitle: ReactNode;
boardEditAction: ReactNode;
boardSettingsAction: ReactNode;
avatar: ReactNode;
userId: string | null;
isAdmin: boolean;
isDockerEnabled: boolean;
board: HeaderBoard | undefined;
searchDisplay: "input" | "icon";
logoDisplay: "logo" | "logoAndText";
boardSwitcher: BoardSwitcherControls;
assistant: ReturnType<typeof useOptionalHomarrAssistant>;
openDockerModal: () => void;
toggleColorScheme: () => void;
label: (key: string) => string;
boardUnavailableLabel: string;
}
const HeaderItem = ({
item,
logo,
logoWithTitle,
boardEditAction,
boardSettingsAction,
avatar,
userId,
isAdmin,
isDockerEnabled,
board,
searchDisplay,
logoDisplay,
boardSwitcher,
assistant,
openDockerModal,
toggleColorScheme,
label,
boardUnavailableLabel,
}: HeaderItemProps) => {
if (item.type === "board") {
if (!board) {
return (
<Tooltip label={boardUnavailableLabel}>
<HeaderButton aria-label={boardUnavailableLabel} disabled>
<IconLayoutDashboard size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
return (
<Tooltip label={board.name}>
<HeaderButton href={`/boards/${encodeURIComponent(board.name)}`} aria-label={board.name}>
<Avatar src={board.logoImageUrl} size={22} radius="sm">
<IconLayoutDashboard size={17} stroke={1.5} />
</Avatar>
</HeaderButton>
</Tooltip>
);
}
if (item.id === "logo") {
return (
<HeaderLogo
display={logoDisplay}
logo={logo}
logoWithTitle={logoWithTitle}
label={label("logo")}
/>
);
}
if (item.id === "boardEdit") return boardEditAction;
if (item.id === "boardSettings") return boardSettingsAction;
if (item.id === "search") {
return (
<TourTarget id="board-search">
<div className={classes.search} data-display={searchDisplay}>
{searchDisplay === "input" ? (
<>
<DesktopSearchInput />
<MobileSearchButton />
</>
) : (
<MobileSearchButton alwaysVisible />
)}
</div>
</TourTarget>
);
}
if (item.id === "home") {
return (
<Tooltip label={label("home")}>
<HeaderButton href="/boards" aria-label={label("home")}>
<IconHome size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
if (item.id === "boardSwitcher") {
return (
<Tooltip label={label("boardSwitcher")}>
<HeaderButton
aria-label={label("boardSwitcher")}
onClick={boardSwitcher.open}
onFocus={boardSwitcher.preload}
onPointerEnter={boardSwitcher.preload}
>
<IconReplace size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
if (item.id === "assistant") {
if (!assistant?.enabled) return null;
return (
<Tooltip label={label("assistant")}>
<Indicator
inline
disabled={!assistant.isRunning && assistant.unreadCount === 0}
color="red"
size={16}
label={assistant.isRunning ? <Loader type="bars" color="white" size={8} /> : assistant.unreadCount}
>
<HeaderButton aria-label={label("assistant")} onClick={assistant.open}>
<IconRobot size={20} stroke={1.5} />
</HeaderButton>
</Indicator>
</Tooltip>
);
}
if (item.id === "docker") {
if (!isDockerEnabled) return null;
return (
<Tooltip label={invariantTechnicalLabels.docker}>
<HeaderButton aria-label={invariantTechnicalLabels.docker} onClick={openDockerModal}>
<IconBrandDocker size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
if (item.id === "settings") {
const href = userId ? `/manage/users/${userId}/general` : "/auth/login";
return (
<Tooltip label={label("settings")}>
<HeaderButton href={href} aria-label={label("settings")}>
<IconSettings size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
if (item.id === "themeToggle") {
return (
<Tooltip label={label("themeToggle")}>
<HeaderButton aria-label={label("themeToggle")} onClick={toggleColorScheme}>
<IconSunMoon size={20} stroke={1.5} />
</HeaderButton>
</Tooltip>
);
}
return (
<TourTarget id="board-user-menu">
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</TourTarget>
);
};
@@ -0,0 +1,22 @@
.root {
display: flex;
align-items: center;
flex: 0 0 auto;
border-radius: var(--mantine-radius-md);
}
.root[data-display="logo"] {
justify-content: center;
width: calc(var(--app-shell-header-height) - 0.5rem);
height: calc(var(--app-shell-header-height) - 0.5rem);
overflow: hidden;
}
.root[data-display="logo"] img {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: calc(var(--app-shell-header-height) - 0.5rem);
object-fit: contain;
}
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import { UnstyledButton } from "@mantine/core";
import { Link } from "@homarr/ui";
import classes from "./header-logo.module.css";
interface HeaderLogoProps {
display: "logo" | "logoAndText";
logo: ReactNode;
logoWithTitle: ReactNode;
label: string;
}
export const HeaderLogo = ({ display, logo, logoWithTitle, label }: HeaderLogoProps) => {
let content = logo;
if (display === "logoAndText") content = logoWithTitle;
return (
<UnstyledButton
component={Link}
href="/"
className={classes.root}
data-display={display}
aria-label={label}
>
{content}
</UnstyledButton>
);
};
@@ -28,12 +28,14 @@ export const DesktopSearchInput = () => {
);
};
export const MobileSearchButton = () => {
export const MobileSearchButton = ({ alwaysVisible = false }: { alwaysVisible?: boolean }) => {
const t = useI18n();
return (
<HeaderButton
data-homarr-dev-benchmark-interaction="search"
onClick={() => openSpotlight()}
className={classes.mobileSearch}
className={alwaysVisible ? undefined : classes.mobileSearch}
aria-label={t("search.placeholder")}
>
<IconSearch size={20} stroke={1.5} />
</HeaderButton>
@@ -8,18 +8,22 @@ import { useHotkeys } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
import { UpdateIndicator } from "./update";
interface UserButtonClientProps {
avatar: ReactNode;
isAdmin: boolean;
isDockerEnabled: boolean;
boardSwitcher: BoardSwitcherControls;
}
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled, boardSwitcher }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const t = useI18n("common.userAvatar.menu");
const session = useSession();
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -45,8 +49,8 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
return (
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled} boardSwitcher={boardSwitcher}>
<UnstyledButton aria-label={t("open")}>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
{avatar}
</UpdateIndicator>
@@ -2,13 +2,19 @@ import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { CurrentUserAvatar } from "~/components/user-avatar";
import type { BoardSwitcherControls } from "~/components/board/board-switcher";
import { UserButtonClient } from "./user-client";
export const UserButton = async () => {
export const UserButton = async ({ boardSwitcher }: { boardSwitcher: BoardSwitcherControls }) => {
const session = await auth();
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
return (
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
<UserButtonClient
avatar={<CurrentUserAvatar size="md" />}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
);
};
@@ -1,8 +1,9 @@
"use client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings";
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
import { homarrLogoPath } from "./constants";
import type { LogoWithTitleProps } from "./logo";
import { Logo, LogoWithTitle } from "./logo";
@@ -12,8 +13,9 @@ interface LogoProps {
const useImageOptions = () => {
const board = useRequiredBoard();
const { branding } = useSettings();
return {
src: board.logoImageUrl ?? homarrLogoPath,
src: board.logoImageUrl?.trim() || branding.logoImageUrl?.trim() || homarrLogoPath,
alt: "Board logo",
shouldUseNextImage: false,
};
@@ -31,12 +33,13 @@ interface CommonLogoWithTitleProps {
export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
const board = useRequiredBoard();
const { branding } = useSettings();
const imageOptions = useImageOptions();
return (
<LogoWithTitle
size={size}
hideTitleOnMobile={hideTitleOnMobile}
title={board.pageTitle ?? homarrPageTitle}
title={board.pageTitle ?? branding.appName}
image={imageOptions}
/>
);
@@ -0,0 +1 @@
export const homarrLogoPath = "/logo/logo.png";
@@ -1,25 +1,39 @@
"use client";
import { useSettings } from "@homarr/settings";
import type { LogoWithTitleProps } from "./logo";
import { Logo, LogoWithTitle } from "./logo";
import { homarrLogoPath } from "./constants";
interface LogoProps {
size: number;
}
export const homarrLogoPath = "/logo/logo.png";
export const homarrPageTitle = "Homarr";
const imageOptions = {
src: homarrLogoPath,
alt: "Homarr logo",
shouldUseNextImage: true,
const useBrandLogo = () => {
const { branding } = useSettings();
const configuredLogoUrl = branding.logoImageUrl?.trim();
return {
title: branding.appName,
image: {
src: configuredLogoUrl || homarrLogoPath,
alt: `${branding.appName} logo`,
shouldUseNextImage: !configuredLogoUrl,
},
};
};
export const HomarrLogo = ({ size }: LogoProps) => <Logo size={size} {...imageOptions} />;
export const HomarrLogo = ({ size }: LogoProps) => {
const brand = useBrandLogo();
return <Logo size={size} {...brand.image} />;
};
interface CommonLogoWithTitleProps {
size: LogoWithTitleProps["size"];
hideTitleOnMobile?: boolean;
}
export const HomarrLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
return <LogoWithTitle size={size} title={homarrPageTitle} image={imageOptions} />;
export const HomarrLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTitleProps) => {
const brand = useBrandLogo();
return <LogoWithTitle size={size} title={brand.title} image={brand.image} hideTitleOnMobile={hideTitleOnMobile} />;
};
@@ -9,14 +9,18 @@ interface LogoProps {
shouldUseNextImage?: boolean;
}
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) =>
shouldUseNextImage ? (
<Image className="logo" src={src} alt={alt} width={size} height={size} />
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) => {
const normalizedSrc = src.trim();
if (normalizedSrc.length === 0) return null;
return shouldUseNextImage ? (
<Image className="logo" src={normalizedSrc} alt={alt} width={size} height={size} />
) : (
// we only want to use next/image for logos that we are sure will be preloaded and are allowed
// eslint-disable-next-line @next/next/no-img-element
<img className="logo" src={src} alt={alt} width={size} height={size} />
<img className="logo" src={normalizedSrc} alt={alt} width={size} height={size} />
);
};
const logoWithTitleSizes = {
lg: { logoSize: 48, titleOrder: 1 },
@@ -15,7 +15,17 @@ export const CommonNavLink = (props: ClientNavigationLink) =>
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
id ? <TourTarget id={id}>{children}</TourTarget> : children;
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
const pathMatches = (pathname: string, href: string, exact = false) => {
if (pathname === href) return true;
if (exact) return false;
return pathname.startsWith(`${href}/`);
};
const getMostSpecificMatchingHref = (pathname: string, items: NavigationLinkHref[]) =>
items
.filter((item) => pathMatches(pathname, item.href, item.exact))
.toSorted((first, second) => second.href.length - first.href.length)
.at(0)?.href;
const useClientPathname = () => {
const pathname = usePathname();
@@ -31,6 +41,7 @@ const useClientPathname = () => {
const NavLinkHref = (props: NavigationLinkHref) => {
const { pathname, isClient } = useClientPathname();
const tourId = props["data-onboarding-tour-id"];
const isActive = props.active ?? (isClient && pathMatches(pathname, props.href, props.exact));
const link = props.external ? (
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
) : (
@@ -39,7 +50,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
label={props.label}
leftSection={props.icon}
href={props.href}
active={isClient && pathMatches(pathname, props.href)}
active={isActive}
/>
);
return withOptionalTourTarget(tourId, link);
@@ -47,11 +58,18 @@ const NavLinkHref = (props: NavigationLinkHref) => {
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const { pathname, isClient } = useClientPathname();
const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href));
const activeItemHref = getMostSpecificMatchingHref(pathname, props.items);
const isActive = isClient && activeItemHref !== undefined;
const [opened, setOpened] = useState(false);
useEffect(() => {
if (isActive) setOpened(true);
}, [isActive]);
const nav = (
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
<NavLink label={props.label} leftSection={props.icon} active={isActive} opened={opened} onChange={setOpened}>
{props.items.map((item) => (
<NavLinkHref key={item.label} {...item} />
<NavLinkHref key={item.label} {...item} active={isClient && activeItemHref === item.href} />
))}
</NavLink>
);
@@ -67,6 +85,8 @@ interface CommonNavigationLinkProps {
interface NavigationLinkHref extends CommonNavigationLinkProps {
href: string;
external?: boolean;
exact?: boolean;
active?: boolean;
}
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
items: NavigationLinkHref[];
@@ -72,6 +72,7 @@ interface CommonNavigationLinkProps {
interface NavigationLinkHref extends CommonNavigationLinkProps {
href: string;
external?: boolean;
exact?: boolean;
}
interface NavigationLinkWithItems extends CommonNavigationLinkProps {
items: NavigationLinkHref[];
+4 -1
View File
@@ -3,6 +3,7 @@
import type { PropsWithChildren } from "react";
import { AppShell } from "@mantine/core";
import { useAtomValue } from "jotai";
import { useSettings } from "@homarr/settings";
import { useOptionalBackgroundProps } from "./background";
import { appShellHeaderHeight } from "./constants";
@@ -20,11 +21,13 @@ export const ClientShell = ({
}: PropsWithChildren<ClientShellProps>) => {
const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps();
const { headerPreferences } = useSettings();
const isHeaderVisible = hasHeader && headerPreferences.visible;
return (
<AppShell
{...backgroundProps}
header={hasHeader ? { height: appShellHeaderHeight } : undefined}
header={isHeaderVisible ? { height: appShellHeaderHeight } : undefined}
navbar={
hasNavigation
? {
@@ -8,10 +8,9 @@ import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
import type { FloatingPosition } from "@mantine/core";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
interface TourShellProps extends PropsWithChildren {
steps: OnboardingTourStep[];
started: boolean;
@@ -79,6 +78,7 @@ const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
const tCommon = useI18n("common.action");
const tTour = useI18n("onboardingTour");
const { branding } = useSettings();
const router = useRouter();
const forwardActionRef = useRef<(() => void) | null>(null);
@@ -145,7 +145,13 @@ export const TourShell = ({ steps, started, onEnd, stepRoutes, position, childre
</Group>
{stepIndexFromController(controller) === 0 && (
<Center py="xs">
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" />
<Image
src={branding.logoImageUrl ?? "/logo/logo.png"}
alt={`${branding.appName} logo`}
w={64}
h={64}
fit="contain"
/>
</Center>
)}
</>
+101 -104
View File
@@ -23,7 +23,7 @@ import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
import { BoardSwitcher } from "./board/board-switcher";
import type { BoardSwitcherControls } from "./board/board-switcher";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
@@ -33,6 +33,7 @@ interface UserAvatarMenuProps {
children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean;
boardSwitcher: BoardSwitcherControls;
}
const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
@@ -41,7 +42,7 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
.map((key) => (key === "mod" ? modifierLabel : `${key.charAt(0).toUpperCase()}${key.slice(1)}`))
.join(" + ");
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, boardSwitcher }: UserAvatarMenuProps) => {
const t = useI18n("common.userAvatar.menu");
const tBoard = useI18n("board");
const session = useSession();
@@ -59,111 +60,107 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
}, [logoutUrl]);
return (
<BoardSwitcher>
{({ open, preload, hotkey }) => (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
<Menu.Item
leftSection={<IconReplace size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkey, t("modifier"))}</Kbd>}
onClick={open}
onFocus={preload}
onPointerEnter={preload}
>
{tBoard("action.switch")}
</Menu.Item>
<Menu.Divider />
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
<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">
<CurrentColorSchemeCombobox />
</Menu.Item>
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
<Menu.Divider />
{Boolean(session.data) && (
<>
{assistant?.enabled && (
<Menu.Item
leftSection={
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
}
rightSection={
assistant.unreadCount > 0 ? (
<Badge size="sm" variant="filled" color="red" circle>
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
</Badge>
) : assistant.isRunning ? (
<Text size="xs" c="dimmed">
{t("assistantThinking")}
</Text>
) : (
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
)
}
onClick={assistant.open}
>
{t("assistant")}
</Menu.Item>
)}
<Menu.Item
component={Link}
href={`/manage/users/${session.data?.user.id}/general`}
leftSection={<IconSettings size="1rem" />}
>
{t("preferences")}
</Menu.Item>
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")}
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{invariantTechnicalLabels.docker}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
{session.status === "authenticated" ? (
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
{t("logout")}
</Menu.Item>
) : (
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
{t("login")}
</Menu.Item>
)}
</Menu.Dropdown>
<Menu.Target>
<Indicator
inline
disabled={!assistant?.isRunning && !assistant?.unreadCount}
color="red"
size={20}
offset={4}
label={
assistant?.isRunning ? (
<Loader type="bars" color="white" size={10} />
) : assistant?.unreadCount ? (
assistant.unreadCount > 99 ? (
"99+"
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentColorSchemeCombobox />
</Menu.Item>
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
<Menu.Divider />
{Boolean(session.data) && (
<>
{assistant?.enabled && (
<Menu.Item
leftSection={
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
}
rightSection={
assistant.unreadCount > 0 ? (
<Badge size="sm" variant="filled" color="red" circle>
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
</Badge>
) : assistant.isRunning ? (
<Text size="xs" c="dimmed">
{t("assistantThinking")}
</Text>
) : (
assistant.unreadCount
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
)
) : undefined
}
}
onClick={assistant.open}
>
{t("assistant")}
</Menu.Item>
)}
<Menu.Item
component={Link}
href={`/manage/users/${session.data?.user.id}/general`}
leftSection={<IconSettings size="1rem" />}
>
{children}
</Indicator>
</Menu.Target>
</Menu>
)}
</BoardSwitcher>
{t("preferences")}
</Menu.Item>
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")}
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{invariantTechnicalLabels.docker}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
{session.status === "authenticated" ? (
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
{t("logout")}
</Menu.Item>
) : (
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
{t("login")}
</Menu.Item>
)}
</Menu.Dropdown>
<Menu.Target>
<Indicator
inline
disabled={!assistant?.isRunning && !assistant?.unreadCount}
color="red"
size={20}
offset={4}
label={
assistant?.isRunning ? (
<Loader type="bars" color="white" size={10} />
) : assistant?.unreadCount ? (
assistant.unreadCount > 99 ? (
"99+"
) : (
assistant.unreadCount
)
) : undefined
}
>
{children}
</Indicator>
</Menu.Target>
</Menu>
);
};