From 83b93e17da1d9bc133c3cb2fe47d4fa303e65fda Mon Sep 17 00:00:00 2001 From: ajnart <49837342+ajnart@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:46:31 +0200 Subject: [PATCH] feat(widgets): add right-click context menu with auto-generated toggle actions - Add WidgetContextMenu component using Mantine 9.3 Menu.ContextMenu - Extend WidgetDefinition with contextActions API for widget-specific actions - Auto-generate context menu toggle items for switch-type widget options - Add 'Search' context action to media-requests widgets (list + stats) - Keep existing dots-button menu for discoverability in edit mode - Add widgetStateRef to WidgetComponentProps for client-side-only state - Add skipContextMenu opt-out on switch option definitions --- .../components/board/items/item-content.tsx | 52 ++-- .../board/items/widget-context-menu.tsx | 273 ++++++++++++++++++ packages/widgets/src/definition.ts | 35 ++- packages/widgets/src/index.tsx | 2 +- .../widgets/src/media-requests/list/index.ts | 13 +- .../widgets/src/media-requests/stats/index.ts | 13 +- packages/widgets/src/options.ts | 1 + 7 files changed, 364 insertions(+), 25 deletions(-) create mode 100644 apps/nextjs/src/components/board/items/widget-context-menu.tsx diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 9f56a7d48..c82e94e8e 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import { Badge, Card } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { QueryErrorResetBoundary } from "@tanstack/react-query"; @@ -16,6 +17,7 @@ import classes from "../sections/item.module.css"; import { useItemActions } from "./item-actions"; import itemContentClasses from "./item-content.module.css"; import { BoardItemMenu } from "./item-menu"; +import { WidgetContextMenu } from "./widget-context-menu"; interface BoardItemContentProps { item: SectionItem; @@ -30,30 +32,37 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => { export const BoardItemContent = ({ item }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); const board = useRequiredBoard(); + const widgetStateRef = useRef | null>(null); + + const cardElement = ( + + + + ); return ( <> - - - + + {cardElement} + {item.advancedOptions.title?.trim() && ( | null>; } const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx new file mode 100644 index 000000000..17ad2853c --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { Menu } from "@mantine/core"; +import { + IconCopy, + IconLayoutKanban, + IconPencil, + IconTrash, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { useEditMode } from "@homarr/boards/edit-mode"; +import { useConfirmModal, useModalAction } from "@homarr/modals"; +import { useSettings } from "@homarr/settings"; +import { translateIfNecessary } from "@homarr/translation"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import type { WidgetContextMenuAction } from "@homarr/widgets"; +import { + reduceWidgetOptionsWithDefaultValues, + widgetImports, +} from "@homarr/widgets"; +import { WidgetEditModal } from "@homarr/widgets/modals"; + +import type { SectionItem } from "~/app/[locale]/boards/_types"; +import { useSectionContext } from "../sections/section-context"; +import { useItemActions } from "./item-actions"; +import { ItemMoveModal } from "./item-move-modal"; + +interface WidgetContextMenuProps { + item: SectionItem; + widgetStateRef: React.MutableRefObject | null>; + children: React.ReactNode; +} + +export const WidgetContextMenu = ({ + item, + widgetStateRef, + children, +}: WidgetContextMenuProps) => { + const [isEditMode] = useEditMode(); + const board = useRequiredBoard(); + const tItem = useScopedI18n("item"); + const t = useI18n(); + const settings = useSettings(); + const { openModal } = useModalAction(WidgetEditModal); + const { openModal: openMoveModal } = useModalAction(ItemMoveModal); + const { openConfirmModal } = useConfirmModal(); + const { + updateItemOptions, + updateItemAdvancedOptions, + updateItemIntegrations, + duplicateItem, + removeItem, + } = useItemActions(); + const { data: integrationData, isPending } = + clientApi.integration.all.useQuery(); + const currentDefinition = useMemo( + () => widgetImports[item.kind].definition, + [item.kind], + ); + const { gridstack } = useSectionContext().refs; + + const options = useMemo( + () => + reduceWidgetOptionsWithDefaultValues( + item.kind, + settings, + item.options, + ) as Record, + [item.kind, settings, item.options], + ); + + type OptionDef = { type: string; skipContextMenu?: boolean }; + const autoToggleActions = useMemo(() => { + const rawOptions = currentDefinition.createOptions( + settings, + ) as unknown as Record; + return Object.entries(rawOptions) + .filter(([, def]) => def.type === "switch" && !def.skipContextMenu) + .map(([key]) => ({ + key: `toggle-${key}`, + label: `widget.${item.kind}.option.${key}.label`, + onClick: () => { + const currentValue = options[key] as boolean | undefined; + const newOptions = { [key]: !currentValue }; + updateItemOptions({ + itemId: item.id, + newOptions: { ...options, ...newOptions }, + }); + }, + })); + }, [currentDefinition, settings, options, item, updateItemOptions]); + + const widgetContextActions = useMemo(() => { + const def = currentDefinition as Record; + if (typeof def.contextActions !== "function") return []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actions = (def.contextActions as any)({ + options, + setOptions: (partial: Record) => { + updateItemOptions({ + itemId: item.id, + newOptions: { ...options, ...partial }, + }); + }, + integrationIds: item.integrationIds, + context: { + isEditMode, + boardId: board.id, + itemId: item.id, + }, + widgetStateRef, + }); + return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; + }, [ + currentDefinition, + options, + item, + updateItemOptions, + isEditMode, + widgetStateRef, + ]); + + const openEditModal = useCallback(() => { + openModal( + { + kind: item.kind, + value: { + advancedOptions: item.advancedOptions, + options: item.options, + integrationIds: item.integrationIds, + }, + onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => { + updateItemOptions({ + itemId: item.id, + newOptions: options, + }); + updateItemAdvancedOptions({ + itemId: item.id, + newAdvancedOptions: advancedOptions, + }); + updateItemIntegrations({ + itemId: item.id, + newIntegrations: integrationIds, + }); + }, + integrationData: (integrationData ?? []).filter( + (integration) => + "supportedIntegrations" in currentDefinition && + (currentDefinition.supportedIntegrations as string[]).some( + (kind) => kind === integration.kind, + ), + ), + integrationSupport: + "supportedIntegrations" in currentDefinition, + settings, + appId: + item.kind === "app" + ? (item.options.appId as string | undefined) + : undefined, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { title: (translateFn: any) => `${translateFn("item.edit.title")} - ${translateFn(`widget.${item.kind}.name`)}` }, + ); + }, [ + openModal, + item, + updateItemOptions, + updateItemAdvancedOptions, + updateItemIntegrations, + integrationData, + currentDefinition, + settings, + ]); + + const openRemoveModal = useCallback(() => { + openConfirmModal({ + title: tItem("remove.title"), + children: tItem("remove.message"), + onConfirm: () => { + removeItem({ itemId: item.id }); + }, + }); + }, [openConfirmModal, tItem, removeItem, item.id]); + + const allActions = useMemo(() => { + const actions: WidgetContextMenuAction[] = []; + + if (isEditMode) { + actions.push({ + key: "edit", + label: tItem("action.edit"), + icon: IconPencil, + onClick: openEditModal, + }); + actions.push({ + key: "moveResize", + label: tItem("action.moveResize"), + icon: IconLayoutKanban, + onClick: () => { + if (!gridstack.current) return; + openMoveModal({ + item, + columnCount: gridstack.current.getColumn(), + gridStack: gridstack.current, + }); + }, + }); + actions.push({ + key: "duplicate", + label: tItem("action.duplicate"), + icon: IconCopy, + onClick: () => duplicateItem({ itemId: item.id }), + }); + } + + const widgetActions: WidgetContextMenuAction[] = [ + ...autoToggleActions, + ...widgetContextActions, + ]; + actions.push(...widgetActions); + + if (isEditMode) { + actions.push({ + key: "remove", + label: tItem("action.remove"), + icon: IconTrash, + onClick: openRemoveModal, + color: "red", + }); + } + + return actions.filter((a) => !a.hidden); + }, [ + isEditMode, + autoToggleActions, + widgetContextActions, + openEditModal, + openRemoveModal, + tItem, + gridstack, + item, + openMoveModal, + duplicateItem, + ]); + + if (isPending) return <>{children}; + + return ( + + {children} + + {allActions.map((action) => { + const label = String(translateIfNecessary(t, action.label)); + const Icon = action.icon; + return ( + : undefined} + onClick={action.onClick} + disabled={action.disabled} + c={action.color} + > + {label} + + ); + })} + + + ); +}; diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 4c65d231e..a48df6b5f 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -1,3 +1,4 @@ +import type React from "react"; import type { LoaderComponent } from "next/dynamic"; import type { QueryClient } from "@tanstack/react-query"; import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import"; @@ -9,7 +10,35 @@ import type { stringOrTranslation } from "@homarr/translation"; import type { TablerIcon } from "@homarr/ui"; import type { WidgetImports } from "."; -import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options"; +import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options"; + +export interface WidgetContextMenuAction { + key: string; + label: stringOrTranslation; + icon?: TablerIcon; + onClick: () => void; + hidden?: boolean; + disabled?: boolean; + color?: string; +} + +export interface WidgetContextMenuContext { + isEditMode: boolean; + boardId: string | undefined; + itemId: string | undefined; +} + +export interface WidgetContextActionProps< + TKind extends WidgetKind, + TOptions extends WidgetOptionsRecord = WidgetOptionsRecord, +> { + kind: TKind; + options: inferOptionsFromDefinition; + setOptions: (partial: Partial>) => void; + integrationIds: string[]; + context: WidgetContextMenuContext; + widgetStateRef: React.MutableRefObject | null>; +} const createWithDynamicImport = (kind: TKind, definition: TDefinition) => @@ -56,6 +85,9 @@ export interface WidgetDefinition { } > >; + contextActions?: ( + props: Omit, "kind">, + ) => WidgetContextMenuAction[]; } export interface WidgetProps { @@ -70,6 +102,7 @@ export type WidgetComponentProps = WidgetProps setOptions: ({ newOptions }: { newOptions: Partial>> }) => void; width: number; height: number; + widgetStateRef?: React.MutableRefObject | null>; }; export type WidgetOptionsRecordOf = WidgetImports[TKind]["definition"]["createOptions"]; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 17e9b8eed..33f131ee5 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -61,7 +61,7 @@ import * as video from "./video"; import * as weather from "./weather"; import * as customApi from "./custom-api"; -export type { WidgetDefinition, WidgetOptionsSettings } from "./definition"; +export type { WidgetDefinition, WidgetContextMenuAction, WidgetContextActionProps, WidgetOptionsSettings } from "./definition"; export type { WidgetComponentProps }; export type { WidgetOptionDefinition, WidgetOptionType } from "./options"; diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts index 8f1a88575..d4e0fcd54 100644 --- a/packages/widgets/src/media-requests/list/index.ts +++ b/packages/widgets/src/media-requests/list/index.ts @@ -1,6 +1,7 @@ -import { IconZoomQuestion } from "@tabler/icons-react"; +import { IconSearch, IconZoomQuestion } from "@tabler/icons-react"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { openMediaRequestSearch } from "@homarr/spotlight"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -14,5 +15,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ }), })); }, + contextActions: ({ integrationIds }) => [ + { + key: "search", + label: (t) => t("search.mode.media.action.search.label"), + icon: IconSearch, + onClick: () => { + openMediaRequestSearch({ integrationIds }); + }, + }, + ], supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts index 25911c894..d49cb8495 100644 --- a/packages/widgets/src/media-requests/stats/index.ts +++ b/packages/widgets/src/media-requests/stats/index.ts @@ -1,6 +1,7 @@ -import { IconChartBar } from "@tabler/icons-react"; +import { IconChartBar, IconSearch } from "@tabler/icons-react"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { openMediaRequestSearch } from "@homarr/spotlight"; import { createWidgetDefinition } from "../../definition"; @@ -9,5 +10,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ createOptions() { return {}; }, + contextActions: ({ integrationIds }) => [ + { + key: "search", + label: (t) => t("search.mode.media.action.search.label"), + icon: IconSearch, + onClick: () => { + openMediaRequestSearch({ integrationIds }); + }, + }, + ], supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index 1d19f628a..fea8704a5 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -13,6 +13,7 @@ import type { ReleasesRepository } from "./releases/releases-repository"; interface CommonInput { defaultValue?: TType; withDescription?: boolean; + skipContextMenu?: boolean; } interface TextInput extends CommonInput {