diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index b23b0b081..69b39a38a 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -111,7 +111,7 @@ export const createBoardLayout = ({ } - logoWithTitle={} + logoWithTitle={} actions={headerActions} boardEditAction={headerBoardEditAction} boardSettingsAction={headerBoardSettingsAction} diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx index 128d3c4cc..2a0830a0b 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core"; -import { useDisclosure, useMediaQuery } from "@mantine/hooks"; +import { useDisclosure } from "@mantine/hooks"; import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; @@ -12,6 +12,7 @@ import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; import { UserAvatar } from "@homarr/ui"; +import { useIsMobile } from "@homarr/ui/hooks"; interface UserProfileAvatarForm { user: RouterOutputs["user"]["getById"]; @@ -25,7 +26,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { }, }); const [opened, { toggle }] = useDisclosure(false); - const isMobile = useMediaQuery("(max-width: 48em)"); + const isMobile = useIsMobile(); const { openConfirmModal } = useConfirmModal(); const tCommon = useI18n("common"); const tManageAvatar = useI18n("user.action.manageAvatar"); 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..c8821aac4 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,7 @@ 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 { 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,6 +20,7 @@ import { getWidgetName } from "@homarr/definitions"; import { translateIfNecessary } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; +import { useIsMobile } from "@homarr/ui/hooks"; import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition"; import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; @@ -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; @@ -134,11 +138,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,6 +227,18 @@ 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]); + + const handleCloseMenu = useCallback(() => { + setMenuOpened(false); + }, []); + if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") { return <>{children}; } @@ -239,21 +256,10 @@ export const WidgetContextMenu = ({ onChange={setMenuOpened} > {children} - + {canOpenAdvancedFocus && ( <> - } - onClick={() => { - if (sourceRef.current) - openAdvancedFocus(item.id, sourceRef.current, { - restoreFocusTarget: - sourceRef.current.querySelector("[data-advanced-focus-trigger]") ?? - sourceRef.current, - }); - }} - > + } onClick={handleOpenAdvancedFocus}> {t("item.advancedFocus.open")} @@ -319,11 +325,39 @@ export const WidgetContextMenu = ({ > {tMenu("settings")} - + ); }; +interface WidgetContextMenuDropdownProps { + opened: boolean; + onClose: () => void; + title: string; + children: ReactNode; +} + +const WidgetContextMenuDropdown = ({ opened, onClose, title, children }: WidgetContextMenuDropdownProps) => { + const isMobile = useIsMobile(); + + if (!isMobile) return {children}; + + return ( + +
+ {children} +
+
+ ); +}; + 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..b35d4f686 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -27,11 +27,12 @@ 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} 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..cdbd10f3f 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, @@ -116,14 +108,15 @@ export const ConfigurableHeader = ({ return ( <> - {headerPreferences.visible ? ( - + +
{hasNavigation ? : null} @@ -137,8 +130,31 @@ export const ConfigurableHeader = ({ {actions ? {actions} : null}
- - ) : ( +
+
+ + {hasNavigation ? : null} + + + + {actions} + {boardEditAction} + {boardSettingsAction} + + + + + + + +
+
+ {!headerPreferences.visible ? ( {hasNavigation ? : null} - )} + ) : null} ); @@ -291,7 +307,7 @@ const HeaderItem = ({ - + {getBoardInitial(board.name)} @@ -299,14 +315,7 @@ const HeaderItem = ({ } if (item.id === "logo") { - return ( - - ); + return ; } if (item.id === "boardEdit") return boardEditAction; @@ -416,3 +425,5 @@ const HeaderItem = ({ ); }; + +const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?"; 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 ( { const t = useI18n("onboardingTour.manage"); const { mutate: completeTour } = clientApi.user.completeTour.useMutation(); - const isMobile = useMediaQuery("(max-width: 48em)"); + const isMobile = useIsMobile(); const pathname = usePathname(); const router = useRouter(); diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index f8552e94a..950536175 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1 +1,2 @@ +export * from "./use-is-mobile"; export * from "./use-translated-mantine-react-table"; diff --git a/packages/ui/src/hooks/use-is-mobile.ts b/packages/ui/src/hooks/use-is-mobile.ts new file mode 100644 index 000000000..8cf9cec1e --- /dev/null +++ b/packages/ui/src/hooks/use-is-mobile.ts @@ -0,0 +1,9 @@ +"use client"; + +import { useMantineTheme } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; + +export const useIsMobile = () => { + const theme = useMantineTheme(); + return useMediaQuery(`(max-width: ${theme.breakpoints.sm})`); +};