diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 9f56a7d48..3d7ecfd3a 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,3 +1,5 @@ +import type { MutableRefObject } from "react"; +import { useRef } from "react"; import { Badge, Card } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { QueryErrorResetBoundary } from "@tanstack/react-query"; @@ -16,6 +18,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 +33,33 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => { export const BoardItemContent = ({ item }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); const board = useRequiredBoard(); + const widgetStateRef = useRef | null>(null); return ( <> - - - + + + + + {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..1dc960f58 --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -0,0 +1,250 @@ +"use client"; + +import type { MutableRefObject, ReactNode } from "react"; +import { useCallback, useMemo } from "react"; +import { Group, Menu, Switch } from "@mantine/core"; +import { IconCopy, IconLayoutKanban, IconSettings, IconTrash } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/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: MutableRefObject | null>; + children: ReactNode; +} + +export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => { + const { data: session } = useSession(); + const [isEditMode] = useEditMode(); + const board = useRequiredBoard(); + const tItem = useScopedI18n("item"); + const tMenu = useScopedI18n("item.menu.label"); + 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 { mutate: saveBoard } = clientApi.board.saveBoard.useMutation(); + 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 toggleOptions = useMemo(() => { + const rawOptions = currentDefinition.createOptions(settings) as unknown as Record; + return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); + }, [currentDefinition, settings]); + + 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, board.id, widgetStateRef]); + + const persistBoard = useCallback( + (updatedItems: typeof board.items) => { + saveBoard({ ...board, items: updatedItems }); + }, + [board, saveBoard], + ); + + const openEditModal = useCallback(() => { + openModal( + { + kind: item.kind, + value: { + advancedOptions: item.advancedOptions, + options: item.options, + integrationIds: item.integrationIds, + }, + onSuccessfulEdit: (editResult) => { + updateItemOptions({ itemId: item.id, newOptions: editResult.options }); + updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions }); + updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds }); + if (!isEditMode) { + persistBoard( + board.items.map((boardItem) => + boardItem.id !== item.id + ? boardItem + : { + ...boardItem, + options: editResult.options, + advancedOptions: editResult.advancedOptions, + integrationIds: editResult.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: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` }, + ); + }, [ + openModal, + item, + updateItemOptions, + updateItemAdvancedOptions, + updateItemIntegrations, + integrationData, + currentDefinition, + settings, + isEditMode, + persistBoard, + board, + ]); + + const handleToggle = useCallback( + (key: string) => (checked: boolean) => { + const newOptions = { ...options, [key]: checked }; + updateItemOptions({ itemId: item.id, newOptions }); + persistBoard( + board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })), + ); + }, + [options, item.id, updateItemOptions, persistBoard, board], + ); + + if (!session) return <>{children}; + + const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); + + return ( + + {children} + + {toggleOptions.length > 0 && ( + <> + {tMenu("options")} + {toggleOptions.map(([key]) => ( + handleToggle(key)(!options[key])}> + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} + + + + ))} + + )} + + {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} + > + {String(translateIfNecessary(t, action.label))} + + ); + })} + + )} + + {isEditMode && ( + <> + {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } + } + onClick={() => { + if (!gridstack.current) return; + openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); + }} + > + {tItem("action.moveResize")} + + } + onClick={() => duplicateItem({ itemId: item.id })} + > + {tItem("action.duplicate")} + + + )} + + <> + {!isEditMode && (toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } + } + onClick={openEditModal} + disabled={isPending} + > + {tMenu("settings")} + + + + {isEditMode && ( + <> + + } + onClick={() => { + openConfirmModal({ + title: tItem("remove.title"), + children: tItem("remove.message"), + onConfirm: () => removeItem({ itemId: item.id }), + }); + }} + > + {tItem("action.remove")} + + + )} + + + ); +}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index b6eb826dc..cbbd87012 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1598,7 +1598,9 @@ }, "menu": { "label": { - "settings": "Settings" + "settings": "Settings", + "options": "Options", + "actions": "Actions" } }, "create": { @@ -4311,7 +4313,7 @@ "notStarted": "Not started" }, "name": { -"management": "Management", + "management": "Management", "dashboard": "Dashboard" } } diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 4c65d231e..a6e769d5d 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,7 @@ export interface WidgetDefinition { } > >; + contextActions?: (props: Omit, "kind">) => WidgetContextMenuAction[]; } export interface WidgetProps { @@ -70,6 +100,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..ca8a5f62f 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -61,7 +61,12 @@ 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..579d7eaaf 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 { @@ -91,6 +92,7 @@ const optionsFactory = { type: "switch" as const, defaultValue: input?.defaultValue ?? false, withDescription: input?.withDescription ?? false, + skipContextMenu: input?.skipContextMenu ?? false, }), text: (input?: TextInput) => ({ type: "text" as const,