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/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index c29eb8252..c8821aac4 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -3,7 +3,6 @@ import type { MutableRefObject, ReactNode, RefObject } from "react"; import { useCallback, useMemo, useState } from "react"; 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"; @@ -21,7 +20,8 @@ import { getWidgetName } from "@homarr/definitions"; import { translateIfNecessary } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; -import type { WidgetContextMenuAction, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; +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"; import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation"; @@ -60,6 +60,8 @@ 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(); @@ -74,7 +76,6 @@ 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) => { @@ -234,29 +235,15 @@ export const WidgetContextMenu = ({ }); }, [item.id, openAdvancedFocus, sourceRef]); + const handleCloseMenu = useCallback(() => { + setMenuOpened(false); + }, []); + if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") { return <>{children}; } const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden); - const actionContent = ( - - ); return ( {children} - {isMobile ? ( - setMenuOpened(false)} - position="bottom" - size="min(80dvh, 40rem)" - title={getWidgetName(item.kind, t)} - classNames={{ content: classes.mobileDrawerContent, body: classes.mobileDrawerBody }} + + {canOpenAdvancedFocus && ( + <> + } onClick={handleOpenAdvancedFocus}> + {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} > -
- {actionContent} -
-
- ) : ( - {actionContent} - )} + + {tCommon("refresh")} + + + + } + onClick={openEditModal} + onFocus={preloadWidgetEditModal} + onPointerEnter={preloadWidgetEditModal} + disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)} + > + {tMenu("settings")} + +
); }; -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; +interface WidgetContextMenuDropdownProps { + opened: boolean; + onClose: () => void; + title: string; + children: ReactNode; } -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"); +const WidgetContextMenuDropdown = ({ opened, onClose, title, children }: WidgetContextMenuDropdownProps) => { + const isMobile = useIsMobile(); + + if (!isMobile) return {children}; 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")} - - + +
+ {children} +
+
); }; diff --git a/apps/nextjs/src/components/onboarding/manage-tour.tsx b/apps/nextjs/src/components/onboarding/manage-tour.tsx index ef3b38f7f..d81ccecb1 100644 --- a/apps/nextjs/src/components/onboarding/manage-tour.tsx +++ b/apps/nextjs/src/components/onboarding/manage-tour.tsx @@ -4,7 +4,6 @@ import type { PropsWithChildren } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour"; -import { useMediaQuery } from "@mantine/hooks"; import { IconAffiliateFilled, IconAppsFilled, @@ -16,6 +15,7 @@ import { import { clientApi } from "@homarr/api/client"; import { createDocumentationLink } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; +import { useIsMobile } from "@homarr/ui/hooks"; import { TourTargetsProvider } from "~/components/layout/header/tour-target"; import { TourShell } from "./tour-shell"; @@ -48,7 +48,7 @@ const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = { export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => { 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})`); +};