From 63e1bc7f6c7614e06e85ee2f582f339c719704d9 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 27 Aug 2026 07:24:40 +0200 Subject: [PATCH] fix: improve mobile header and board menus --- .../app/[locale]/boards/_layout-creator.tsx | 5 +- .../board/board-switcher.module.css | 20 ++ .../src/components/board/board-switcher.tsx | 8 +- .../items/widget-context-menu.module.css | 14 + .../board/items/widget-context-menu.tsx | 250 ++++++++++++------ apps/nextjs/src/components/layout/header.tsx | 6 +- .../header/configurable-header.module.css | 63 ++++- .../layout/header/configurable-header.tsx | 76 ++++-- .../src/components/layout/logo/board-logo.tsx | 6 + apps/nextjs/src/components/layout/shell.tsx | 4 +- 10 files changed, 322 insertions(+), 130 deletions(-) create mode 100644 apps/nextjs/src/components/board/items/widget-context-menu.module.css diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index b23b0b081..befd802c5 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -13,7 +13,7 @@ import { ModalProvider } from "@homarr/modals"; import { MainHeader } from "~/components/layout/header"; import { appShellLogoHeight } from "~/components/layout/constants"; -import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; +import { BoardLogo, BoardLogoWithName, BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { ClientShell } from "~/components/layout/shell"; import { BoardTourGate } from "~/components/onboarding/board-tour-gate"; import { env } from "~/env"; @@ -111,7 +111,8 @@ export const createBoardLayout = ({ } - logoWithTitle={} + logoWithTitle={} + mobileLogoWithTitle={} actions={headerActions} boardEditAction={headerBoardEditAction} boardSettingsAction={headerBoardSettingsAction} diff --git a/apps/nextjs/src/components/board/board-switcher.module.css b/apps/nextjs/src/components/board/board-switcher.module.css index 6843fe497..e4bbc3aea 100644 --- a/apps/nextjs/src/components/board/board-switcher.module.css +++ b/apps/nextjs/src/components/board/board-switcher.module.css @@ -15,6 +15,10 @@ outline: none; } +.results { + min-height: 0; +} + .searchInput { border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent); background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent); @@ -44,3 +48,19 @@ border-color: var(--mantine-primary-color-filled); box-shadow: var(--mantine-shadow-md); } + +@media (max-width: $mantine-breakpoint-sm) { + .modalContent { + height: calc(100dvh - var(--modal-y-offset) * 2); + } + + .modalBody, + .content { + min-height: 0; + height: 100%; + } + + .results { + flex: 1; + } +} diff --git a/apps/nextjs/src/components/board/board-switcher.tsx b/apps/nextjs/src/components/board/board-switcher.tsx index 8b9505aae..786ad582a 100644 --- a/apps/nextjs/src/components/board/board-switcher.tsx +++ b/apps/nextjs/src/components/board/board-switcher.tsx @@ -228,7 +228,13 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { radius="xl" classNames={{ input: classes.searchInput }} /> - + {results} diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.module.css b/apps/nextjs/src/components/board/items/widget-context-menu.module.css new file mode 100644 index 000000000..80c9cf80d --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-context-menu.module.css @@ -0,0 +1,14 @@ +.mobileDrawerContent { + height: min(80dvh, rem(640px)); + max-height: 80dvh; + overscroll-behavior: contain; +} + +.mobileDrawerBody { + padding-bottom: max(var(--mantine-spacing-md), env(safe-area-inset-bottom)); +} + +.mobileActionList { + display: flex; + flex-direction: column; +} diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index a4fc9b52a..c29eb8252 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -2,7 +2,8 @@ import type { MutableRefObject, ReactNode, RefObject } from "react"; import { useCallback, useMemo, useState } from "react"; -import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core"; +import { Drawer, Group, Loader, Menu, Text, Tooltip } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { useIsFetching, useQueryClient } from "@tanstack/react-query"; @@ -20,7 +21,7 @@ import { getWidgetName } from "@homarr/definitions"; import { translateIfNecessary } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; -import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; +import type { WidgetContextMenuAction, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition"; import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation"; @@ -31,6 +32,9 @@ import { useBoardPermissions } from "../permissions/client"; import { useItemActions } from "./item-actions"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; import { matchesWidgetItemQuery } from "./widget-query-scope"; +import classes from "./widget-context-menu.module.css"; + +type ToggleOption = [key: string, option: { type: string; skipContextMenu?: boolean }]; interface WidgetContextMenuProps { item: SectionItem; @@ -56,8 +60,6 @@ export const WidgetContextMenu = ({ const { hasChangeAccess } = useBoardPermissions(board); const { updateAndPersistBoard } = usePersistBoard(board); const t = useI18n(); - const tMenu = useI18n("item.menu.label"); - const tCommon = useI18n("common.action"); const settings = useSettings(); const { openModal } = useModalAction(LazyWidgetEditModal); const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); @@ -72,6 +74,7 @@ export const WidgetContextMenu = ({ const { open: openAdvancedFocus } = useAdvancedFocus(); const integrationsWithInteractAccess = useIntegrationsWithInteractAccess(); const [menuOpened, setMenuOpened] = useState(false); + const isMobile = useMediaQuery("(max-width: 48em)"); const persistBoard = useCallback( (updater: (previous: typeof board) => typeof board) => { @@ -134,11 +137,12 @@ export const WidgetContextMenu = ({ [item.id, persistBoard], ); - type OptionDef = { type: string; skipContextMenu?: boolean }; const toggleOptions = useMemo(() => { if (!canConfigureWidget) return []; - const rawOptions = definition.createOptions(settings) as unknown as Record; - return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu); + const rawOptions = definition.createOptions(settings) as unknown as Record; + return Object.entries(rawOptions).filter( + ([, option]) => option.type === "switch" && !option.skipContextMenu, + ) as ToggleOption[]; }, [canConfigureWidget, definition, settings]); const widgetContextActions = @@ -222,11 +226,37 @@ export const WidgetContextMenu = ({ [setItemOptions], ); + const handleOpenAdvancedFocus = useCallback(() => { + if (!sourceRef.current) return; + openAdvancedFocus(item.id, sourceRef.current, { + restoreFocusTarget: + sourceRef.current.querySelector("[data-advanced-focus-trigger]") ?? sourceRef.current, + }); + }, [item.id, openAdvancedFocus, sourceRef]); + if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") { return <>{children}; } const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden); + const actionContent = ( + + ); return ( {children} - - {canOpenAdvancedFocus && ( - <> - } - onClick={() => { - if (sourceRef.current) - openAdvancedFocus(item.id, sourceRef.current, { - restoreFocusTarget: - sourceRef.current.querySelector("[data-advanced-focus-trigger]") ?? - sourceRef.current, - }); - }} - > - {t("item.advancedFocus.open")} - - - - )} - - {toggleOptions.length > 0 && ( - <> - {tMenu("options")} - {toggleOptions.map(([key]) => ( - - {t(`${getWidgetOptionTranslationNamespace(item.kind, key)}.label` as never)} - - ))} - - )} - - {visibleWidgetActions.length > 0 && ( - <> - {toggleOptions.length > 0 && } - {tMenu("actions")} - {visibleWidgetActions.map((action) => { - const Icon = action.icon; - return ( - : undefined} - onClick={action.onClick} - disabled={action.disabled} - color={action.color} - > - {translateIfNecessary(t, action.label)} - - ); - })} - - )} - - {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } - : } - onClick={handleRefetch} - disabled={isWidgetFetching} + {isMobile ? ( + setMenuOpened(false)} + position="bottom" + size="min(80dvh, 40rem)" + title={getWidgetName(item.kind, t)} + classNames={{ content: classes.mobileDrawerContent, body: classes.mobileDrawerBody }} > - - {tCommon("refresh")} - - - - } - onClick={openEditModal} - onFocus={preloadWidgetEditModal} - onPointerEnter={preloadWidgetEditModal} - disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)} - > - {tMenu("settings")} - - +
+ {actionContent} +
+ + ) : ( + {actionContent} + )}
); }; +interface WidgetContextActionsProps { + canOpenAdvancedFocus: boolean; + onOpenAdvancedFocus: () => void; + toggleOptions: ToggleOption[]; + options: Record; + onToggle: (key: string) => (checked: boolean) => void; + itemKind: SectionItem["kind"]; + visibleWidgetActions: WidgetContextMenuAction[]; + isWidgetFetching: boolean; + onRefetch: () => void; + queryClient: QueryClient; + matchesWidgetQuery: (queryKey: QueryKey) => boolean; + onEdit: () => void; + canConfigureWidget: boolean; + isIntegrationDataPending: boolean; +} + +const WidgetContextActions = ({ + canOpenAdvancedFocus, + onOpenAdvancedFocus, + toggleOptions, + options, + onToggle, + itemKind, + visibleWidgetActions, + isWidgetFetching, + onRefetch, + queryClient, + matchesWidgetQuery, + onEdit, + canConfigureWidget, + isIntegrationDataPending, +}: WidgetContextActionsProps) => { + const t = useI18n(); + const tMenu = useI18n("item.menu.label"); + const tCommon = useI18n("common.action"); + + return ( + <> + {canOpenAdvancedFocus && ( + <> + } onClick={onOpenAdvancedFocus}> + {t("item.advancedFocus.open")} + + + + )} + + {toggleOptions.length > 0 && ( + <> + {tMenu("options")} + {toggleOptions.map(([key]) => ( + + {t(`${getWidgetOptionTranslationNamespace(itemKind, key)}.label` as never)} + + ))} + + )} + + {visibleWidgetActions.length > 0 && ( + <> + {toggleOptions.length > 0 && } + {tMenu("actions")} + {visibleWidgetActions.map((action) => { + const Icon = action.icon; + return ( + : undefined} + onClick={action.onClick} + disabled={action.disabled} + color={action.color} + > + {translateIfNecessary(t, action.label)} + + ); + })} + + )} + + {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } + : } + onClick={onRefetch} + disabled={isWidgetFetching} + > + + {tCommon("refresh")} + + + + } + onClick={onEdit} + onFocus={preloadWidgetEditModal} + onPointerEnter={preloadWidgetEditModal} + disabled={!canConfigureWidget || isIntegrationDataPending} + > + {tMenu("settings")} + + + ); +}; + interface WidgetQueryStatusProps { queryClient: QueryClient; matchesQuery: (queryKey: QueryKey) => boolean; diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index 4f268039e..d47896097 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -10,6 +10,7 @@ import { HomarrLogo, HomarrLogoWithTitle } from "./logo/homarr-logo"; interface Props { logo?: ReactNode; logoWithTitle?: ReactNode; + mobileLogoWithTitle?: ReactNode; actions?: ReactNode; boardEditAction?: ReactNode; boardSettingsAction?: ReactNode; @@ -19,6 +20,7 @@ interface Props { export const MainHeader = async ({ logo, logoWithTitle, + mobileLogoWithTitle, actions, boardEditAction, boardSettingsAction, @@ -27,11 +29,13 @@ export const MainHeader = async ({ const session = await auth(); const isAdmin = Boolean(session?.user.permissions.includes("admin")); const isDockerEnabled = isAdmin && env.ENABLE_DOCKER; + const resolvedLogoWithTitle = logoWithTitle ?? ; return ( } - logoWithTitle={logoWithTitle ?? } + logoWithTitle={resolvedLogoWithTitle} + mobileLogoWithTitle={mobileLogoWithTitle ?? resolvedLogoWithTitle} actions={actions} boardEditAction={boardEditAction} boardSettingsAction={boardSettingsAction} diff --git a/apps/nextjs/src/components/layout/header/configurable-header.module.css b/apps/nextjs/src/components/layout/header/configurable-header.module.css index 2b851745d..698943cb6 100644 --- a/apps/nextjs/src/components/layout/header/configurable-header.module.css +++ b/apps/nextjs/src/components/layout/header/configurable-header.module.css @@ -2,6 +2,15 @@ overflow: hidden; } +.header[data-desktop-visible="false"] { + display: none; +} + +.desktopContent { + width: 100%; + height: 100%; +} + .content { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -64,6 +73,41 @@ flex: 0 0 auto; } +.mobileContent { + display: none; + align-items: center; + justify-content: space-between; + gap: var(--mantine-spacing-xs); + width: 100%; + height: 100%; + padding-inline: var(--mantine-spacing-xs); +} + +.mobileIdentity, +.mobileActions { + min-width: 0; +} + +.mobileIdentity { + flex: 1 1 auto; + overflow: hidden; +} + +.mobileIdentity > :last-child { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +} + +.mobileIdentity h2 { + overflow: hidden; + text-overflow: ellipsis; +} + +.mobileActions { + flex: 0 0 auto; +} + .floatingControls { position: fixed; z-index: var(--homarr-z-index-board-header); @@ -111,18 +155,21 @@ } @media (max-width: $mantine-breakpoint-sm) { - .content { - gap: var(--mantine-spacing-xs); - padding-inline: var(--mantine-spacing-xs); + .header[data-desktop-visible="false"] { + display: block; } - .zone { - gap: var(--mantine-spacing-xs); + .desktopContent { + display: none; } - .search { - width: auto; - flex: 0 0 auto; + .mobileContent { + display: flex; + } + + .floatingControls, + .floatingControlsCorner { + display: none; } } diff --git a/apps/nextjs/src/components/layout/header/configurable-header.tsx b/apps/nextjs/src/components/layout/header/configurable-header.tsx index 5d4bf0ef3..48457f357 100644 --- a/apps/nextjs/src/components/layout/header/configurable-header.tsx +++ b/apps/nextjs/src/components/layout/header/configurable-header.tsx @@ -2,15 +2,7 @@ import type { ReactNode } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; -import { - AppShellHeader, - Avatar, - Group, - Indicator, - Loader, - Tooltip, - useMantineColorScheme, -} from "@mantine/core"; +import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core"; import { IconBrandDocker, IconHome, @@ -46,6 +38,7 @@ import classes from "./configurable-header.module.css"; interface ConfigurableHeaderProps { logo: ReactNode; logoWithTitle: ReactNode; + mobileLogoWithTitle: ReactNode; actions?: ReactNode; boardEditAction?: ReactNode; boardSettingsAction?: ReactNode; @@ -64,6 +57,7 @@ const floatingControlsDismissDelayMs = 900; export const ConfigurableHeader = ({ logo, logoWithTitle, + mobileLogoWithTitle, actions, boardEditAction, boardSettingsAction, @@ -116,14 +110,15 @@ export const ConfigurableHeader = ({ return ( <> - {headerPreferences.visible ? ( - + +
{hasNavigation ? : null} @@ -137,8 +132,36 @@ export const ConfigurableHeader = ({ {actions ? {actions} : null}
- - ) : ( +
+
+ + {hasNavigation ? : null} + + + + {actions} + {boardEditAction} + {boardSettingsAction} + + + + + + + +
+
+ {!headerPreferences.visible ? ( {hasNavigation ? : null} - )} + ) : null} ); @@ -291,7 +314,7 @@ const HeaderItem = ({ - + {getBoardInitial(board.name)} @@ -299,14 +322,7 @@ const HeaderItem = ({ } if (item.id === "logo") { - return ( - - ); + return ; } if (item.id === "boardEdit") return boardEditAction; @@ -416,3 +432,5 @@ const HeaderItem = ({ ); }; + +const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?"; diff --git a/apps/nextjs/src/components/layout/logo/board-logo.tsx b/apps/nextjs/src/components/layout/logo/board-logo.tsx index dfa9daf28..b3d3fadd1 100644 --- a/apps/nextjs/src/components/layout/logo/board-logo.tsx +++ b/apps/nextjs/src/components/layout/logo/board-logo.tsx @@ -44,3 +44,9 @@ export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTi /> ); }; + +export const BoardLogoWithName = ({ size }: Pick) => { + const board = useRequiredBoard(); + const imageOptions = useImageOptions(); + return ; +}; diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx index 5f14a7da5..40a3c99f9 100644 --- a/apps/nextjs/src/components/layout/shell.tsx +++ b/apps/nextjs/src/components/layout/shell.tsx @@ -22,12 +22,12 @@ export const ClientShell = ({ const collapsed = useAtomValue(navigationCollapsedAtom); const backgroundProps = useOptionalBackgroundProps(); const { headerPreferences } = useSettings(); - const isHeaderVisible = hasHeader && headerPreferences.visible; + const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 }; return (