"use client"; import type { MutableRefObject, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useState } 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 { useIsFetching, useQueryClient } from "@tanstack/react-query"; 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 { TranslationFunction } from "@homarr/translation"; 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 queryClient = useQueryClient(); const widgetQueryKey = useMemo( () => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]), [currentDefinition, item.kind], ); const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0; const handleRefetch = useCallback(() => { void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" }); }, [queryClient, widgetQueryKey]); 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")} )} <> {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && } : } onClick={handleRefetch} disabled={isWidgetFetching} > {tMenu("refresh")} } 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")} )} ); }; interface WidgetQueryStatusProps { queryClient: QueryClient; queryKey: QueryKey; isFetching: boolean; t: TranslationFunction; } const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => { const [, setTick] = useState(0); useEffect(() => { const id = setInterval(() => setTick((n) => n + 1), 1000); return () => clearInterval(id); }, []); const queries = queryClient.getQueryCache().findAll({ queryKey }); 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"); const timestamps = queries.map((q) => q.state.dataUpdatedAt).filter(Boolean); const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0; const seconds = Math.floor((Date.now() - latest) / 1000); const ageLabel = seconds < 5 ? "just now" : seconds < 60 ? `${seconds}s ago` : `${Math.floor(seconds / 60)}m ago`; 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")} · {ageLabel} ); } return ( {t("item.menu.status.success")} · {ageLabel} ); };