import type { ComponentType, CSSProperties, MutableRefObject, PropsWithChildren } from "react"; import { memo, Suspense, use, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import dynamic from "next/dynamic"; import { Box, Button, Center, Loader, Portal } from "@mantine/core"; import { useElementSize, useIsomorphicEffect } from "@mantine/hooks"; import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; import type { FallbackProps } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { getWidgetName } from "@homarr/definitions"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import { WidgetError } from "@homarr/widgets/errors"; import { createWidgetRuntimeState, getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus as definitionSupportsAdvancedFocus, } from "@homarr/widgets/definition"; import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { WidgetCardShell, WidgetTitleBadge } from "@homarr/widgets/widget-card-shell"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { getLogicalTrackSize } from "~/components/board/layout"; import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas"; import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css"; import { useAdvancedFocus } from "../advanced-focus/context"; import { startAdvancedFocusEntrance } from "../advanced-focus/entrance"; import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry"; import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface"; import { redirectShiftWheel } from "../advanced-focus/wheel"; import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host"; import { useItemActions } from "./item-actions"; import itemContentClasses from "./item-content.module.css"; import { WidgetContextMenu } from "./widget-context-menu"; import { removeWidgetDataQueries } from "./widget-query-recovery"; import { matchesWidgetItemQuery } from "./widget-query-scope"; const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), { ssr: false, }); interface BoardItemContentProps { item: SectionItem; } type BoardItemCardProps = PropsWithChildren<{ item: SectionItem; innerRef: (element: HTMLDivElement | null) => void; }>; const BoardItemCard = ({ item, innerRef, children }: BoardItemCardProps) => { const board = useRequiredBoard(); return ( {children} ); }; const WidgetDefinitionLoadError = ({ error, resetErrorBoundary, item, innerRef, }: FallbackProps & Pick) => (
); export const BoardItemContent = ({ item }: BoardItemContentProps) => { const { ref, width: measuredWidth, height: measuredHeight } = useElementSize(); const { getEntryRuntime } = useBoardGridPortalHost(); const { widgetStateRef, widgetRuntimeRef } = getEntryRuntime(item.id, createBoardItemRuntime); const width = measuredWidth || getLogicalTrackSize(item.width); const height = measuredHeight || getLogicalTrackSize(item.height); return ( } >
} >
); }; const createBoardItemRuntime = () => ({ widgetStateRef: { current: null } as MutableRefObject | null>, widgetRuntimeRef: { current: createWidgetRuntimeState() } as WidgetRuntimeRef, }); interface LoadedBoardItemContentProps { item: SectionItem; width: number; height: number; widgetStateRef: MutableRefObject | null>; widgetRuntimeRef: WidgetRuntimeRef; contentRef: (element: HTMLDivElement | null) => void; } const LoadedBoardItemContent = ({ item, width, height, widgetStateRef, widgetRuntimeRef, contentRef, }: LoadedBoardItemContentProps) => { const { definition, Component } = use(loadWidgetResources(item.kind)); const sourceRef = useRef(null); const advancedFocusTriggerRef = useRef(null); const cardRef = useRef(null); const previewEntranceFrameRef = useRef(null); const board = useRequiredBoard(); const boardCanvasScale = useBoardCanvasScale(); const t = useI18n(); const [isEditMode] = useEditMode(); const settings = useSettings(); const menuItem = useMemo( () => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }), [definition, item, settings], ); const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]); const isWidgetRefreshing = useIsFetching({ predicate: (query) => query.state.data !== undefined && matchesWidgetItemQuery( query.queryKey, widgetQueryKeys, { itemId: item.id, boardId: board.id, integrationIds: item.integrationIds, options: menuItem.options, runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef), }, definition.queryMatcher, ), }) > 0; const [manualSurface, setManualSurface] = useState(null); const [surfacePortalTarget, setSurfacePortalTarget] = useState(null); const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus(); const { width: viewportWidth, height: viewportHeight } = viewportSize; const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition); const widgetName = getWidgetName(item.kind, t); const previewDimensions = useMemo( () => ({ width, height, scale: boardCanvasScale }), [boardCanvasScale, height, width], ); const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName }); const advancedViewId = `advanced-focus-${item.id}`; const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null; const isAdvanced = activeFocus !== null; const advancedRect = activeFocus ? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight }) : null; const closePosition = advancedRect ? getAdvancedFocusClosePosition(advancedRect, viewportWidth) : null; const isPreview = activeFocus?.activation === "preview"; const isManual = activeFocus?.activation === "manual"; useIsomorphicEffect(() => { if (!supportsAdvancedFocus) return; const target = document.createElement("div"); target.className = advancedFocusClasses.surfacePortalTarget ?? ""; setSurfacePortalTarget(target); return () => target.remove(); }, [supportsAdvancedFocus]); useEffect(() => () => dismiss(item.id), [dismiss, item.id]); const openAdvancedView = () => { if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose: true, restoreFocusTarget: advancedFocusTriggerRef.current ?? sourceRef.current, }); }; useEffect(() => { const surface = isManual ? manualSurface : cardRef.current; if (!isAdvanced || !surface) return; const handleWheel = (event: WheelEvent) => { if (!event.shiftKey) return; const multiplier = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? surface.clientHeight : 1; const delta = (event.deltaY || event.deltaX) * multiplier; if (!redirectShiftWheel(surface, event.target, delta)) return; event.preventDefault(); event.stopPropagation(); }; surface.addEventListener("wheel", handleWheel, { capture: true, passive: false }); return () => surface.removeEventListener("wheel", handleWheel, { capture: true }); }, [isAdvanced, isManual, manualSurface]); const cancelPreviewEntrance = useCallback(() => { if (previewEntranceFrameRef.current === null) return; cancelAnimationFrame(previewEntranceFrameRef.current); previewEntranceFrameRef.current = null; }, []); const mountCompactHost = useCallback( (host: HTMLDivElement | null) => { if (!host || !surfacePortalTarget || isAdvanced) return; cancelPreviewEntrance(); if (host.childNodes.length !== 1 || host.firstChild !== surfacePortalTarget) { host.replaceChildren(surfacePortalTarget); } }, [cancelPreviewEntrance, isAdvanced, surfacePortalTarget], ); const mountPreviewHost = useCallback( (host: HTMLDivElement | null) => { if (!host || !surfacePortalTarget || !isPreview) { if (!host) cancelPreviewEntrance(); return; } if (activeFocus?.phase !== "visible") { host.append(surfacePortalTarget); return; } const card = cardRef.current; const readyClass = advancedFocusClasses.surfaceReady; if (!card || !readyClass) return; cancelPreviewEntrance(); previewEntranceFrameRef.current = startAdvancedFocusEntrance(surfacePortalTarget, host, card, readyClass); }, [activeFocus?.phase, cancelPreviewEntrance, isPreview, surfacePortalTarget], ); const mountManualHost = useCallback( (host: HTMLDivElement | null) => { if (!host || !surfacePortalTarget || !isManual) return; cancelPreviewEntrance(); host.append(surfacePortalTarget); }, [cancelPreviewEntrance, isManual, surfacePortalTarget], ); useEffect(() => cancelPreviewEntrance, [cancelPreviewEntrance]); const previewStyle = isPreview && advancedRect && activeFocus ? ({ left: advancedRect.left, top: advancedRect.top, width: advancedRect.width, height: advancedRect.height, "--focus-translate-x": `${activeFocus.sourceRect.left - advancedRect.left}px`, "--focus-translate-y": `${activeFocus.sourceRect.top - advancedRect.top}px`, "--focus-scale-x": advancedRect.width > 0 ? activeFocus.sourceRect.width / advancedRect.width : 1, "--focus-scale-y": advancedRect.height > 0 ? activeFocus.sourceRect.height / advancedRect.height : 1, } as CSSProperties) : undefined; const widgetCard = ( (
)} >
{isWidgetRefreshing && ( )}
); return ( <> {isEditMode && } { if (supportsAdvancedFocus && sourceRef.current) hover(item.id, sourceRef.current); }} onPointerLeave={() => { if (supportsAdvancedFocus) leave(item.id); }} className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)} > {!isEditMode && supportsAdvancedFocus && ( )}
{!supportsAdvancedFocus && widgetCard}
{surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)} {isPreview && (
)} {isManual && activeFocus && advancedRect && closePosition && ( close()} >
)} {!isAdvanced && ( )} ); }; interface InnerContentProps { item: SectionItem; width: number; height: number; displayScale: number; widgetStateRef: MutableRefObject | null>; widgetRuntimeRef: WidgetRuntimeRef; displayMode: "compact" | "advanced"; definition: WidgetDefinition; Component: ComponentType>; } const InnerContent = memo(function InnerContent({ item, definition, Component, ...dimensions }: InnerContentProps) { const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options); const { removeItem, updateItemOptions } = useItemActions(); const updateOptions = ({ newOptions }: { newOptions: Record }) => updateItemOptions({ itemId: item.id, newOptions }); const widgetSupportsIntegrations = "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1; const queryClient = useQueryClient(); const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind); return ( {({ reset }) => ( { removeWidgetDataQueries(queryClient, widgetQueryKeys); reset(); }} resetKeys={[item]} fallbackRender={({ resetErrorBoundary, error }) => (
)} >
removeItem({ itemId: item.id })} setOptions={(partialNewOptions) => updateOptions({ newOptions: { ...options, ...partialNewOptions.newOptions, }, }) } {...dimensions} />
)}
); }); const Throw = ({ when, error }: { when: boolean; error: Error }) => { if (when) throw error; return null; };