"use client"; import type { MutableRefObject, ReactNode } from "react"; import { useCallback, useMemo, useRef } from "react"; import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core"; import { IconAlertTriangle, IconCircleCheck, IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash, } from "@tabler/icons-react"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { useTimeAgo } from "@homarr/common"; import { useConfirmModal, useModalAction } from "@homarr/modals"; import type { useSettings } from "@homarr/settings"; import { translateIfNecessary } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { TranslationFunction } from "@homarr/translation"; import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition"; import { getWidgetQueryKeys } from "@homarr/widgets/definition"; import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import type { UseGridstackRefs } from "../sections/gridstack/use-gridstack"; import { useSectionContext } from "../sections/section-context"; import { useWidgetContextMenu } from "./widget-context-menu-provider"; import { useItemActions } from "./item-actions"; import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; interface WidgetContextMenuProps { item: SectionItem; definition: WidgetDefinition; widgetStateRef: MutableRefObject | null>; children: ReactNode; } const LONG_PRESS_DELAY_MS = 500; /** * Per-item trigger only. The menu itself lives once at board level — see * WidgetContextMenuProvider for why. */ export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => { const { open } = useWidgetContextMenu(); const { gridstack } = useSectionContext().refs; const longPressTimer = useRef | null>(null); const cancelLongPress = useCallback(() => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } }, []); if (!open) return <>{children}; return ( // display:contents keeps GridStack's layout maths seeing the original child box.
{ event.preventDefault(); open({ item, definition, widgetStateRef, gridstack, x: event.clientX, y: event.clientY }); }} onTouchStart={(event) => { const touch = event.touches[0]; if (!touch) return; const { clientX, clientY } = touch; longPressTimer.current = setTimeout( () => open({ item, definition, widgetStateRef, gridstack, x: clientX, y: clientY }), LONG_PRESS_DELAY_MS, ); }} onTouchEnd={cancelLongPress} onTouchMove={cancelLongPress} onTouchCancel={cancelLongPress} > {children}
); }; type WidgetContextMenuDropdownProps = Omit & { settings: ReturnType; /** * Passed in rather than read from SectionContext: the menu is mounted once at board * level, outside any section, while the item that opened it is inside one. */ gridstack: UseGridstackRefs["gridstack"]; }; export const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings, gridstack, }: WidgetContextMenuDropdownProps) => { const [isEditMode] = useEditMode(); const board = useRequiredBoard(); const tItem = useScopedI18n("item"); const tMenu = useScopedI18n("item.menu.label"); const t = useI18n(); const hasSupportedIntegrations = "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0; const { openModal } = useModalAction(LazyWidgetEditModal); const { openModal: openMoveModal } = useModalAction(LazyItemMoveModal); const { openConfirmModal } = useConfirmModal(); const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = useItemActions(); const { data: integrationData, isPending } = clientApi.integration.all.useQuery(undefined, { enabled: hasSupportedIntegrations, }); const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation(); const queryClient = useQueryClient(); const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]); const isWidgetFetching = useIsFetching({ predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)), }) > 0; const handleRefetch = useCallback(() => { void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" }))); }, [queryClient, widgetQueryKeys]); const options = useMemo( () => reduceWidgetOptionsWithDefinition(definition, settings, item.options), [definition, settings, item.options], ); type OptionDef = { type: string; skipContextMenu?: boolean }; const toggleOptions = useMemo(() => { const rawOptions = definition.createOptions(settings) as unknown as Record; return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); }, [definition, settings]); const widgetContextActions = useMemo(() => { const def = definition as unknown 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[]; }, [definition, 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, definition, 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 definition && (definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), ), integrationSupport: "supportedIntegrations" in definition, 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, definition, 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], ); const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); return ( <> {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) && } } onFocus={preloadItemMoveModal} onPointerEnter={preloadItemMoveModal} 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")} )} <> {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && } : } onClick={handleRefetch} disabled={isWidgetFetching} > {tMenu("refresh")} } onClick={openEditModal} onFocus={preloadWidgetEditModal} onPointerEnter={preloadWidgetEditModal} disabled={hasSupportedIntegrations && isPending} > {tMenu("settings")} {isEditMode && ( <> } onClick={() => { openConfirmModal({ title: tItem("remove.title"), children: tItem("remove.message"), onConfirm: () => removeItem({ itemId: item.id }), }); }} > {tItem("action.remove")} )} ); }; interface WidgetQueryStatusProps { queryClient: QueryClient; queryKeys: QueryKey[]; isFetching: boolean; t: TranslationFunction; } const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => { const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))]; const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean); const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0; const ageLabel = useTimeAgo(new Date(latest)); if (isFetching) { return ( {t("item.menu.status.loading")} ); } if (queries.length === 0) { return ( {t("item.menu.status.idle")} ); } const hasError = queries.some((q) => q.state.status === "error"); if (hasError) { const errorQuery = queries.find((q) => q.state.status === "error"); const errorMessage = errorQuery?.state.error instanceof Error ? errorQuery.state.error.message : String(errorQuery?.state.error ?? "Unknown error"); return ( {t("item.menu.status.error")} {latest > 0 && ` · ${ageLabel}`} ); } return ( {t("item.menu.status.success")} {latest > 0 && ` · ${ageLabel}`} ); };