diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index dcd800dc8..6fbb301d0 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -198,9 +198,13 @@ In the modal you can edit the app that you want to display:
Current Edit item modal for an App tile -
Use the Edit item modal to choose the app and configure how its tile is displayed.
+
+ The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board. +
+When adding a widget, set its initial grid width and height directly in the preview. These size controls are only shown before the widget is added. + As soon as you save your changes, the tile on the board will refresh and display your app. Resize from any visible edge or corner handle. For a Container, activate its top-left grip before using its resize handles. Press and hold a handle, move the pointer, and release it when the tile has the size you want. diff --git a/apps/docs/docs/getting-started/img/onboarding-edit-item-current.png b/apps/docs/docs/getting-started/img/onboarding-edit-item-current.png index 8fe412aa7..b86859e7e 100644 Binary files a/apps/docs/docs/getting-started/img/onboarding-edit-item-current.png and b/apps/docs/docs/getting-started/img/onboarding-edit-item-current.png differ diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 7c9ab7c4e..395178901 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -16,7 +16,7 @@ import { BoardSetupChecklist } from "~/components/board/board-setup-checklist"; import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, - getLogicalTrackSize, + getLogicalGridSize, LOGICAL_GRID_GAP, getRootSectionForLane, } from "~/components/board/layout"; @@ -61,16 +61,16 @@ export const ClientBoard = () => { const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right"); const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount] .filter((columnCount) => columnCount > 0) - .map(getLogicalTrackSize); + .map(getLogicalGridSize); const logicalWidth = laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP; const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId); const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null; const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING); const gridTemplateColumns = [ - leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null, - `${getLogicalTrackSize(mainColumnCount)}px`, - rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null, + leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null, + `${getLogicalGridSize(mainColumnCount)}px`, + rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null, ] .filter((value) => value !== null) .join(" "); diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index 15deba72f..551f2d656 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -1,5 +1,7 @@ "use client"; +/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */ + import { use, useCallback, useState } from "react"; import { ActionIcon, Affix, Card } from "@mantine/core"; import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react"; @@ -71,6 +73,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps ), integrationSupport: hasIntegrationSupport, settings, + previewDimensions: dimensions, }, { title(translate) { @@ -86,10 +89,10 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps } finally { setIsEditorLoading(false); } - }, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]); + }, [currentDefinition, dimensions, kind, openWidgetEditModal, settings, state, t, utils]); const toggleEditMode = useCallback(() => { - setEditMode((editMode) => !editMode); + setEditMode((currentEditMode) => !currentEditMode); showSuccessNotification({ message: editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled"), }); diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx index 454e7a805..36cf7edf9 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx @@ -1,8 +1,14 @@ import { Suspense } from "react"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { Center, Loader } from "@mantine/core"; +import { getHomeBoardAsync } from "@homarr/api/board-server"; +import { IntegrationProvider } from "@homarr/auth/client"; +import { auth } from "@homarr/auth/next"; +import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server"; +import { BoardPreviewProvider } from "@homarr/boards/context"; import type { WidgetKind } from "@homarr/definitions"; +import { ModalProvider } from "@homarr/modals"; import { widgetKinds } from "@homarr/widgets/manifest"; import { WidgetPreviewPageContent } from "./_content"; @@ -16,12 +22,24 @@ export default async function WidgetPreview(props: Props) { if (!widgetKinds.includes(kind as WidgetKind)) { notFound(); } + const session = await auth(); + if (!session) { + redirect(`/auth/login?callbackUrl=${encodeURIComponent(`/widgets/${kind}`)}`); + } + + const [integrations, board] = await Promise.all([getIntegrationsWithPermissionsAsync(session), getHomeBoardAsync()]); return ( -
- }> - - -
+ + + +
+ }> + + +
+
+
+
); } diff --git a/apps/nextjs/src/components/board/items/item-content.module.css b/apps/nextjs/src/components/board/items/item-content.module.css index 7ec4b3a16..3e4a93a20 100644 --- a/apps/nextjs/src/components/board/items/item-content.module.css +++ b/apps/nextjs/src/components/board/items/item-content.module.css @@ -1,16 +1,3 @@ -.badge { - @mixin dark { - --background-color: rgb(from var(--mantine-color-dark-6) r g b / var(--opacity)); - --border-color: rgb(from var(--mantine-color-dark-4) r g b / var(--opacity)); - } - @mixin light { - --background-color: rgb(from var(--mantine-color-white) r g b / var(--opacity)); - --border-color: rgb(from var(--mantine-color-gray-3) r g b / var(--opacity)); - } - background-color: var(--background-color) !important; - border-color: var(--border-color) !important; -} - .settingsButton { opacity: 0; pointer-events: none; diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index a8a394b92..f695dc002 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,9 +1,8 @@ -import type { ComponentType, CSSProperties, MutableRefObject } from "react"; +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 type { CardProps } from "@mantine/core"; -import { Badge, Box, Button, Card, Center, Loader, Portal } from "@mantine/core"; +import { Box, Button, Center, Loader, Portal } from "@mantine/core"; import { useElementSize, useIsomorphicEffect } from "@mantine/hooks"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; @@ -24,9 +23,11 @@ import { } 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"; @@ -34,7 +35,6 @@ import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface"; import { redirectShiftWheel } from "../advanced-focus/wheel"; import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host"; -import classes from "../sections/item.module.css"; import { useItemActions } from "./item-actions"; import itemContentClasses from "./item-content.module.css"; import { WidgetContextMenu } from "./widget-context-menu"; @@ -48,47 +48,28 @@ interface BoardItemContentProps { item: SectionItem; } -const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => { - if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const; - if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" } as const; - if (hasCustomCssClasses) return {}; - return { overflowX: "hidden", overflowY: "auto" } as const; -}; - -type BoardItemCardProps = CardProps & { +type BoardItemCardProps = PropsWithChildren<{ item: SectionItem; innerRef: (element: HTMLDivElement | null) => void; -}; +}>; -const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => { +const BoardItemCard = ({ item, innerRef, children }: BoardItemCardProps) => { const board = useRequiredBoard(); return ( - 0), - "--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined, - }, - }} p={0} > {children} - + ); }; @@ -167,6 +148,7 @@ const LoadedBoardItemContent = ({ const cardRef = useRef(null); const previewEntranceFrameRef = useRef(null); const board = useRequiredBoard(); + const boardCanvasScale = useBoardCanvasScale(); const t = useI18n(); const [isEditMode] = useEditMode(); const settings = useSettings(); @@ -180,6 +162,10 @@ const LoadedBoardItemContent = ({ 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; @@ -291,8 +277,11 @@ const LoadedBoardItemContent = ({ : undefined; const widgetCard = ( - 0), - "--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined, - }, - }} style={previewStyle} > @@ -345,15 +322,16 @@ const LoadedBoardItemContent = ({ /> - + ); return ( <> - {isEditMode && } + {isEditMode && } - {!isAdvanced && item.advancedOptions.title?.trim() && ( - - {item.advancedOptions.title} - + /> )} ); diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 2b3a79dcc..8cb01d47b 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -21,6 +21,7 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit- interface BoardItemMenuProps { item: SectionItem; definition: WidgetDefinition; + previewDimensions: { width: number; height: number; scale?: number }; resetErrorBoundary?: () => void; } @@ -31,7 +32,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => { return ; }; -const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => { +const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBoundary }: BoardItemMenuProps) => { const { data: session } = useSession(); const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); const refResetErrorBoundaryOnNextRender = useRef(false); @@ -89,6 +90,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM integrationSupport: "supportedIntegrations" in definition, settings, itemId: item.id, + previewDimensions, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, { diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index cb2f6913b..24dee4e5e 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -30,6 +30,7 @@ import { getIntegrationName, getWidgetName, getRootSectionLane, + widgetDefaultSizes, widgetIntegrationSupport, widgetKinds, widgetKindsWithOptionalIntegrations, @@ -47,6 +48,7 @@ import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr import type { EmptySection } from "~/app/[locale]/boards/_types"; import { useSetupAnalytics } from "~/components/create/setup-analytics"; import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; +import { getSectionGridColumnCount } from "../sections/grid/section-grid-placements"; import { useItemActions } from "./item-actions"; import type { WidgetConnectionStatus } from "./item-select-data"; import { @@ -76,6 +78,8 @@ export interface WidgetItemData { } const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 }; +const defaultWidgetSize = { width: 1, height: 1 }; +const maximumWidgetHeight = 64; const ItemSelectModalContent = ({ actions, @@ -124,6 +128,13 @@ const ItemSelectModalContent = ({ const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId) ? targetSectionId : (placementOptions[0]?.value ?? null); + const maximumWidgetSize = useMemo(() => { + if (!effectiveSectionId) return defaultWidgetSize; + return { + width: Math.max(1, getSectionGridColumnCount(board, currentLayoutId, effectiveSectionId) ?? 1), + height: maximumWidgetHeight, + }; + }, [board, currentLayoutId, effectiveSectionId]); const [loadingSelection, setLoadingSelection] = useState(null); const selectionLock = useRef(false); @@ -246,6 +257,7 @@ const ItemSelectModalContent = ({ try { const definition = await loadWidgetDefinition("customApi"); const itemId = createId(); + const initialSize = widgetDefaultSizes.customApi ?? defaultWidgetSize; const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); const options = { ...defaultOptions, @@ -263,7 +275,7 @@ const ItemSelectModalContent = ({ options, integrationIds: [], }, - onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => { + onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }, selectedSize) => { const updatedBoard = createItem({ id: itemId, kind: "customApi", @@ -271,12 +283,14 @@ const ItemSelectModalContent = ({ options: configuredOptions, targetSectionId: effectiveSectionId ?? undefined, advancedOptions, + size: selectedSize ?? initialSize, }); notifyCreated(updatedBoard, itemId, customWidgetDefinition.name); }, integrationData: [], integrationSupport: false, settings, + previewResize: { initialSize, maximumSize: maximumWidgetSize }, }, { title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`, @@ -311,6 +325,7 @@ const ItemSelectModalContent = ({ const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations); const itemId = createId(); const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); + const initialSize = widgetDefaultSizes[kind] ?? defaultWidgetSize; const openEditor = ( availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[], ) => { @@ -326,7 +341,7 @@ const ItemSelectModalContent = ({ options: defaultOptions, integrationIds: selectedIntegrationIds, }, - onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { + onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }, selectedSize) => { const updatedBoard = createItem({ id: itemId, kind, @@ -334,6 +349,7 @@ const ItemSelectModalContent = ({ integrationIds: newIntegrationIds, targetSectionId: effectiveSectionId ?? undefined, advancedOptions, + size: selectedSize ?? initialSize, }); if (!notifyCreated(updatedBoard, itemId, getWidgetName(kind, t))) return; trackSetup("widget-completed", { @@ -346,6 +362,7 @@ const ItemSelectModalContent = ({ integrationData: availableIntegrations, integrationSupport: hasIntegrationSupport, settings, + previewResize: { initialSize, maximumSize: maximumWidgetSize }, }, { title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`, diff --git a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx index 0a6f448b2..9e80607e6 100644 --- a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx +++ b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx @@ -1,5 +1,6 @@ "use client"; +import type { PropsWithChildren } from "react"; import { Suspense, use, useCallback, useMemo, useState } from "react"; import { Center, Loader } from "@mantine/core"; import { ErrorBoundary } from "react-error-boundary"; @@ -7,16 +8,49 @@ import type { FallbackProps } from "react-error-boundary"; import { clientApi } from "@homarr/api/client"; import type { WidgetKind } from "@homarr/definitions"; -import { createModal, modalSizeForm, useModalAction } from "@homarr/modals"; +import { widgetDefaultSizes } from "@homarr/definitions"; +import { createModal, useModalAction } from "@homarr/modals"; import { WidgetError } from "@homarr/widgets/errors"; import type * as WidgetModalsModule from "@homarr/widgets/modals"; -import type { WidgetEditModalProps } from "@homarr/widgets/modals"; +import type { WidgetEditModalProps, WidgetEditModalSize } from "@homarr/widgets/modals"; +import { loadWidgetComponent } from "@homarr/widgets/manifest"; import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select"; +import { AssistantContext, useOptionalHomarrAssistant } from "~/components/assistant/assistant-context"; +import { getLogicalTrackSize } from "~/components/board/layout"; import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form"; import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form"; +const defaultWidgetSize = { width: 1, height: 1 }; +const ignoreAssistantAction = () => undefined; +const ignoreAssistantPrompt = () => false; +const ignoreAssistantRefresh = () => Promise.resolve(); +const getPreviewDimensions = (size: WidgetEditModalSize) => ({ + width: getLogicalTrackSize(size.width), + height: getLogicalTrackSize(size.height), +}); + +const PreviewRuntimeWrapper = ({ children }: PropsWithChildren) => { + const assistant = useOptionalHomarrAssistant(); + const previewAssistant = useMemo(() => { + if (!assistant) return null; + return { + ...assistant, + open: ignoreAssistantAction, + close: ignoreAssistantAction, + toggle: ignoreAssistantAction, + sendPrompt: ignoreAssistantPrompt, + refreshCurrentView: ignoreAssistantRefresh, + setWidgetVisible: ignoreAssistantAction, + activateWidget: ignoreAssistantAction, + }; + }, [assistant]); + + if (!previewAssistant) return children; + return {children}; +}; + let widgetEditModalPromise: Promise | undefined; export const loadWidgetEditModal = () => { @@ -83,6 +117,7 @@ interface LazyWidgetEditModalContentProps { const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { const { WidgetEditModal } = use(loadWidgetEditModal()); + const { default: PreviewComponent } = use(loadWidgetComponent(props.innerProps.kind)); const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal); const Component = WidgetEditModal.component; const utils = clientApi.useUtils(); @@ -114,6 +149,18 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id)); return [...existing, ...additional]; }, [props.innerProps.integrationData, createdIntegrations]); + const previewDimensions = useMemo(() => { + if (props.innerProps.previewDimensions) return props.innerProps.previewDimensions; + const size = widgetDefaultSizes[props.innerProps.kind] ?? defaultWidgetSize; + return { + width: getLogicalTrackSize(size.width), + height: getLogicalTrackSize(size.height), + }; + }, [props.innerProps.kind, props.innerProps.previewDimensions]); + let previewResize = props.innerProps.previewResize; + if (previewResize) { + previewResize = { ...previewResize, getDimensions: getPreviewDimensions }; + } return ( { ...props.innerProps, integrationData: combinedIntegrationData, integrationEditForm: LazyIntegrationEditForm, + previewComponent: PreviewComponent, + previewDimensions, + previewResize, + previewWrapper: PreviewRuntimeWrapper, onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined, }} /> @@ -157,6 +208,10 @@ export const LazyWidgetEditModal = createModal> defaultTitle(t) { return t("item.edit.title"); }, - size: modalSizeForm, + size: "100rem", + transitionProps: { + transition: "pop", + duration: 180, + }, closeOnClickOutside: false, }); diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index 13c40383b..f0ec8debf 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -35,6 +35,7 @@ import { matchesWidgetItemQuery } from "./widget-query-scope"; interface WidgetContextMenuProps { item: SectionItem; definition: WidgetDefinition; + previewDimensions: { width: number; height: number; scale?: number }; widgetStateRef: MutableRefObject | null>; widgetRuntimeRef: WidgetRuntimeRef; sourceRef: RefObject; @@ -44,6 +45,7 @@ interface WidgetContextMenuProps { export const WidgetContextMenu = ({ item, definition, + previewDimensions, widgetRuntimeRef, sourceRef, children, @@ -160,7 +162,7 @@ export const WidgetContextMenu = ({ definition, value: { advancedOptions: item.advancedOptions, - options: item.options, + options, integrationIds: item.integrationIds, }, onSuccessfulEdit: (editResult) => { @@ -189,6 +191,9 @@ export const WidgetContextMenu = ({ ), integrationSupport: definition.supportedIntegrations !== undefined, settings, + itemId: item.id, + boardId: board.id, + previewDimensions, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -197,12 +202,15 @@ export const WidgetContextMenu = ({ }, ); }, [ + board.id, definition, integrationData, item, handleRefetch, openModal, + options, persistBoard, + previewDimensions, settings, updateItemAdvancedOptions, updateItemIntegrations, diff --git a/apps/nextjs/src/components/board/layout/README.md b/apps/nextjs/src/components/board/layout/README.md index 6eb9c412f..54358e498 100644 --- a/apps/nextjs/src/components/board/layout/README.md +++ b/apps/nextjs/src/components/board/layout/README.md @@ -2,17 +2,16 @@ The board uses fixed logical units: -- one `1 × 1` item is exactly `200 × 200` logical pixels; -- the gap matches Mantine's `sm` spacing at `12` logical pixels, giving a `212` pixel track pitch; -- multi-cell items include the gaps between their cells (`2 × 1` is `412 × 200`); +- one grid track has a `212 × 212` logical-pixel footprint; +- cards are inset by `10` visual pixels on every side, matching the legacy GridStack gutter; +- multi-cell items occupy complete track footprints (`2 × 1` is `424 × 212` before the card inset); - the canvas is zoomed once with one uniform scale derived from its available width. -Items keep the same logical dimensions at every viewport. The viewport reserves -the painted size while the inner canvas remains at its logical dimensions, so a -widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay -stable. The visual canvas always fits the available width, every root lane -keeps at least one viewport of height, and edit-mode previews extend it downward -as needed. +Grid footprints keep the same logical dimensions at every viewport. Widget cards +use a scale-compensated inset so their painted size and content measurements +match the legacy responsive grid. The visual canvas always fits the available +width, every root lane keeps at least one viewport of height, and edit-mode +previews extend it downward as needed. The read-only renderer should: diff --git a/apps/nextjs/src/components/board/layout/board-lanes.ts b/apps/nextjs/src/components/board/layout/board-lanes.ts index b7345fa10..8e7256fa9 100644 --- a/apps/nextjs/src/components/board/layout/board-lanes.ts +++ b/apps/nextjs/src/components/board/layout/board-lanes.ts @@ -2,7 +2,7 @@ import type { BoardLane } from "@homarr/definitions"; import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions"; import type { Board, EmptySection } from "~/app/[locale]/boards/_types"; -import { getLogicalTrackSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry"; +import { getLogicalGridSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry"; import { getCollapsedDisplayLayout } from "./reflow"; import type { GridPlacement } from "./types"; @@ -90,5 +90,5 @@ export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) => return [getLayoutRowCount(displayPlacements)]; }); - return getLogicalTrackSize(Math.max(0, ...rowCounts)); + return getLogicalGridSize(Math.max(0, ...rowCounts)); }; diff --git a/apps/nextjs/src/components/board/layout/geometry.ts b/apps/nextjs/src/components/board/layout/geometry.ts index 449e74d4d..558b168b7 100644 --- a/apps/nextjs/src/components/board/layout/geometry.ts +++ b/apps/nextjs/src/components/board/layout/geometry.ts @@ -10,12 +10,23 @@ export const getLogicalTrackSize = (trackCount: number) => { return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP; }; +/** + * Returns the complete footprint occupied by grid tracks. Cards are inset + * inside this footprint so their visual gutters stay fixed while the canvas + * is scaled. + */ +export const getLogicalGridSize = (trackCount: number) => { + const normalizedTrackCount = Math.max(0, toFiniteNumber(trackCount, "trackCount")); + + return normalizedTrackCount * LOGICAL_GRID_PITCH; +}; + export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({ position: "absolute", left: x * LOGICAL_GRID_PITCH, top: y * LOGICAL_GRID_PITCH, - width: getLogicalTrackSize(w), - height: getLogicalTrackSize(h), + width: getLogicalGridSize(w), + height: getLogicalGridSize(h), }); export const getLayoutRowCount = (placements: readonly GridCoordinates[]) => @@ -25,7 +36,7 @@ export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1; if (!Number.isFinite(visualScale) || visualScale <= 0) return 1; - return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH)); + return Math.max(1, Math.ceil(visualHeight / visualScale / LOGICAL_GRID_PITCH)); }; export const normalizeGridPlacement = ( diff --git a/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts b/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts index 373a5ec6d..8dcb55fd0 100644 --- a/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts @@ -8,7 +8,7 @@ import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/ite import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, - getLogicalTrackSize, + getLogicalGridSize, getRootSectionForLane, } from "../index"; @@ -131,6 +131,6 @@ describe("board lanes", () => { }, ]; - expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalTrackSize(4)); + expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalGridSize(4)); }); }); diff --git a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts index 0db173bb6..ca1887013 100644 --- a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts @@ -3,24 +3,28 @@ import { describe, expect, test } from "vitest"; import { getGridRowCountForVisualHeight, getLayoutRowCount, + getLogicalGridSize, getLogicalItemStyle, getLogicalTrackSize, normalizeGridPlacement, } from "../index"; describe("fixed dashboard geometry", () => { - test("keeps a 1x1 item exactly 200px with a fixed 12px gap", () => { + test("separates content track sizes from complete grid footprints", () => { expect(getLogicalTrackSize(1)).toBe(200); expect(getLogicalTrackSize(0.5)).toBe(94); expect(getLogicalTrackSize(2)).toBe(412); expect(getLogicalTrackSize(3)).toBe(624); + expect(getLogicalGridSize(1)).toBe(212); + expect(getLogicalGridSize(2)).toBe(424); + expect(getLogicalGridSize(3)).toBe(636); expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({ position: "absolute", left: 212, top: 424, - width: 412, - height: 624, + width: 424, + height: 636, }); }); diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts index 79349d94c..bc059c3e6 100644 --- a/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts +++ b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts @@ -1,4 +1,4 @@ -import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; +import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import type { GridPlacement } from "~/components/board/layout"; import type { GridResizeDirection } from "./types"; @@ -38,7 +38,7 @@ export const getSnappedGridCoordinates = ({ target: RectangleLike; columnCount: number; }) => { - const logicalWidth = getLogicalTrackSize(columnCount); + const logicalWidth = getLogicalGridSize(columnCount); const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0; if (!Number.isFinite(visualScale) || visualScale <= 0) return null; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index 4e8051c76..32245dfe6 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -39,7 +39,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { getWidgetName } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; -import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; +import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import { beginGridTransaction, cancelGridTransaction, @@ -792,8 +792,8 @@ export default function GridEditor({ data-grid-depth={depth} style={ { - width: getLogicalTrackSize(columnCount), - height: getLogicalTrackSize(rowCount), + width: getLogicalGridSize(columnCount), + height: getLogicalGridSize(rowCount), "--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`, } as CSSProperties } @@ -1234,8 +1234,8 @@ const getEntryVisualScale = (element: HTMLElement | null, placement: SectionGrid if (!element) return null; const rectangle = element.getBoundingClientRect(); const scale = { - x: rectangle.width / getLogicalTrackSize(placement.w), - y: rectangle.height / getLogicalTrackSize(placement.h), + x: rectangle.width / getLogicalGridSize(placement.w), + y: rectangle.height / getLogicalGridSize(placement.h), }; return Number.isFinite(scale.x) && scale.x > 0 && Number.isFinite(scale.y) && scale.y > 0 ? scale : null; }; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-preview-geometry.ts b/apps/nextjs/src/components/board/sections/grid/grid-preview-geometry.ts index af340ba78..d9d16b218 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-preview-geometry.ts +++ b/apps/nextjs/src/components/board/sections/grid/grid-preview-geometry.ts @@ -1,4 +1,4 @@ -import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; +import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import type { GridPlacement } from "~/components/board/layout"; export interface GridPreviewDomState { @@ -75,8 +75,8 @@ export const clearGridPreviewGeometry = ( const applyPreviewGeometry = (element: HTMLElement, controlled: GridPlacement, preview: GridPlacement) => { element.style.setProperty("--board-grid-preview-x", `${(preview.x - controlled.x) * LOGICAL_GRID_PITCH}px`); element.style.setProperty("--board-grid-preview-y", `${(preview.y - controlled.y) * LOGICAL_GRID_PITCH}px`); - element.style.setProperty("--board-grid-preview-width", `${getLogicalTrackSize(preview.w)}px`); - element.style.setProperty("--board-grid-preview-height", `${getLogicalTrackSize(preview.h)}px`); + element.style.setProperty("--board-grid-preview-width", `${getLogicalGridSize(preview.w)}px`); + element.style.setProperty("--board-grid-preview-height", `${getLogicalGridSize(preview.h)}px`); element.setAttribute("data-grid-preview", "true"); setPlacementAttributes(element, preview); }; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-preview-layer.tsx b/apps/nextjs/src/components/board/sections/grid/grid-preview-layer.tsx index d7397b6b2..20881a97f 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-preview-layer.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-preview-layer.tsx @@ -3,7 +3,7 @@ import type { RefObject } from "react"; import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react"; -import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout"; +import { getLayoutRowCount, getLogicalItemStyle, getLogicalGridSize } from "~/components/board/layout"; import type { TransactionalGridState } from "./dnd"; import type { GridInteractionStore } from "./grid-interaction-store"; import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry"; @@ -87,7 +87,7 @@ export const GridPreviewLayer = ({ } else { delete grid.dataset.dndPreviewRevision; } - grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`; + grid.style.height = `${getLogicalGridSize(renderedRowCount)}px`; const viewport = grid.parentElement; if (viewport?.hasAttribute("data-section-id")) { @@ -98,7 +98,7 @@ export const GridPreviewLayer = ({ // stray value would silently blow the cap open for the rest of the session, well after any // drag ended, since nothing else would ever clear it back out. if (maxRowCount === null) { - viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`); + viewport.style.setProperty("--board-grid-drag-height", `${getLogicalGridSize(renderedRowCount)}px`); } else { viewport.style.removeProperty("--board-grid-drag-height"); } diff --git a/apps/nextjs/src/components/board/sections/grid/grid-resize-outline.ts b/apps/nextjs/src/components/board/sections/grid/grid-resize-outline.ts index 547dfe69d..8bdcf63a8 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-resize-outline.ts +++ b/apps/nextjs/src/components/board/sections/grid/grid-resize-outline.ts @@ -1,4 +1,4 @@ -import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; +import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import type { GridPlacement } from "~/components/board/layout"; interface ResizeOutlinePreview { @@ -31,8 +31,8 @@ export const createGridResizeOutlineController = ( outline.style.position = "absolute"; outline.style.left = `${(preview.placement.x - visualX) * LOGICAL_GRID_PITCH}px`; outline.style.top = `${(preview.placement.y - visualY) * LOGICAL_GRID_PITCH}px`; - outline.style.width = `${getLogicalTrackSize(preview.placement.w)}px`; - outline.style.height = `${getLogicalTrackSize(preview.placement.h)}px`; + outline.style.width = `${getLogicalGridSize(preview.placement.w)}px`; + outline.style.height = `${getLogicalGridSize(preview.placement.h)}px`; outline.dataset.gridResizeValid = String(preview.valid); }; diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.module.css b/apps/nextjs/src/components/board/sections/grid/section-grid.module.css index c08746422..d24f5d776 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.module.css +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.module.css @@ -50,6 +50,10 @@ overflow: visible; } +.staticItem[data-type="item"] > .contentMount { + inset: calc(10px * var(--board-canvas-inverse-scale, 1)); +} + .staticItem:has([data-board-container-label]) { z-index: 2; } diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx index cec4616c9..fc4642641 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx @@ -14,7 +14,7 @@ import { getEditableCanvasAttributes, getGridRowCountForVisualHeight, getLayoutRowCount, - getLogicalTrackSize, + getLogicalGridSize, getReadonlyCanvasAttributes, normalizeGridPlacement, } from "~/components/board/layout"; @@ -138,9 +138,9 @@ export const SectionGrid = ({ // of expanding to fit every widget, so its viewport height is capped independently of rowCount. const isScrollableContainer = section.kind === "container" && section.options.scrollable; const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount; - const logicalWidth = getLogicalTrackSize(columnCount); - const logicalHeight = getLogicalTrackSize(rowCount); - const viewportHeight = getLogicalTrackSize(viewportRowCount); + const logicalWidth = getLogicalGridSize(columnCount); + const logicalHeight = getLogicalGridSize(rowCount); + const viewportHeight = getLogicalGridSize(viewportRowCount); // A collapsed container's compact coordinates are display-only. Its own // nested grid stays inactive until an explicit edit interaction expands it. const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id); diff --git a/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts index 5cb587347..b6f5588f9 100644 --- a/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts +++ b/apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts @@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { getCollapsedDisplayLayout, LOGICAL_GRID_CELL_SIZE } from "../../../layout"; +import { getCollapsedDisplayLayout, getLogicalGridSize } from "../../../layout"; import { FixedGridItem } from "../fixed-grid-item"; const mocks = vi.hoisted(() => ({ @@ -250,14 +250,14 @@ describe("fixed grid item behavior", () => { expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).toBeNull(); }); - test("renders a view-only 1x1 card at the fixed 200px logical size", () => { + test("renders a view-only 1x1 card at the complete grid footprint", () => { mocks.editMode = false; renderWeather(root); const item = container.querySelector('[data-grid-id="weather"]'); expect(item).not.toBeNull(); - expect(item?.style.width).toBe(`${LOGICAL_GRID_CELL_SIZE}px`); - expect(item?.style.height).toBe(`${LOGICAL_GRID_CELL_SIZE}px`); + expect(item?.style.width).toBe(`${getLogicalGridSize(1)}px`); + expect(item?.style.height).toBe(`${getLogicalGridSize(1)}px`); expect(item?.getAttribute("role")).toBeNull(); act(() => item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))); diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-preview-geometry.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-preview-geometry.spec.ts index edaf4c96d..7d4ecb10a 100644 --- a/apps/nextjs/src/components/board/sections/grid/test/grid-preview-geometry.spec.ts +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-preview-geometry.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test } from "vitest"; -import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; +import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry"; @@ -65,8 +65,8 @@ describe("grid preview geometry", () => { expect(active?.getAttribute("data-grid-preview")).toBe("true"); expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe(`${LOGICAL_GRID_PITCH}px`); expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe(`${LOGICAL_GRID_PITCH}px`); - expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalTrackSize(3)}px`); - expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalTrackSize(4)}px`); + expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalGridSize(3)}px`); + expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalGridSize(4)}px`); expect(active?.dataset.gridW).toBe("3"); expect(active?.dataset.gridH).toBe("4"); }); diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-resize-outline.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-resize-outline.spec.ts index b5633d68d..e329b2faf 100644 --- a/apps/nextjs/src/components/board/sections/grid/test/grid-resize-outline.spec.ts +++ b/apps/nextjs/src/components/board/sections/grid/test/grid-resize-outline.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test, vi } from "vitest"; -import { getLogicalTrackSize } from "~/components/board/layout"; +import { getLogicalGridSize } from "~/components/board/layout"; import { createGridResizeOutlineController } from "../grid-resize-outline"; @@ -30,8 +30,8 @@ describe("grid resize outline", () => { const outline = shell.querySelector("[data-grid-resize-outline]"); expect(outline?.style.left).toBe("0px"); expect(outline?.style.top).toBe("0px"); - expect(outline?.style.width).toBe(`${getLogicalTrackSize(1)}px`); - expect(outline?.style.height).toBe(`${getLogicalTrackSize(2)}px`); + expect(outline?.style.width).toBe(`${getLogicalGridSize(1)}px`); + expect(outline?.style.height).toBe(`${getLogicalGridSize(2)}px`); expect(outline?.dataset.gridResizeValid).toBe("false"); controller.schedule(null); diff --git a/packages/boards/src/context.tsx b/packages/boards/src/context.tsx index 2e8137b5e..e777217e6 100644 --- a/packages/boards/src/context.tsx +++ b/packages/boards/src/context.tsx @@ -10,12 +10,16 @@ import { clientApi } from "@homarr/api/client"; import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "./layout-selection"; import { updateBoardName } from "./updater"; -const BoardContext = createContext<{ - board: RouterOutputs["board"]["getBoardByName"]; +type Board = RouterOutputs["board"]["getBoardByName"]; + +interface BoardContextValue { + board: Board; layoutOverrideId: string | null; currentLayout: string; initialViewportWidth: number; -} | null>(null); +} + +const BoardContext = createContext(null); const subscribeToViewport = (onStoreChange: () => void) => { if (typeof window === "undefined") return () => undefined; @@ -37,6 +41,23 @@ const subscribeToViewport = (onStoreChange: () => void) => { }; }; +export const BoardPreviewProvider = ({ children, board }: PropsWithChildren<{ board: Board }>) => { + const currentLayout = board.layouts[0]?.id ?? ""; + + return ( + + {children} + + ); +}; + export const BoardProvider = ({ children, initialBoard, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index dc27c91c8..f206fc80c 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2083,10 +2083,12 @@ "label": "Section or rail" }, "width": { - "label": "Width" + "label": "Width", + "shortLabel": "W" }, "height": { - "label": "Height" + "label": "Height", + "shortLabel": "H" }, "xOffset": { "label": "X offset" @@ -2115,6 +2117,13 @@ "loading": "Loading integration", "loadError": "This integration could not be loaded. Try again before changing its connection settings." }, + "preview": { + "integrationRequired": "Choose a connection to preview this widget.", + "size": "Size" + }, + "settings": { + "label": "Widget settings" + }, "advancedOptions": { "label": "Advanced options", "title": "Advanced item options" diff --git a/packages/widgets/package.json b/packages/widgets/package.json index c54a4ebae..2daffc1b5 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -30,7 +30,8 @@ "./widget-integration-select": "./src/widget-integration-select.tsx", "./modals": "./src/modals/index.ts", "./prefetch": "./src/prefetch.ts", - "./refetch-intervals": "./src/refetch-intervals.ts" + "./refetch-intervals": "./src/refetch-intervals.ts", + "./widget-card-shell": "./src/widget-card-shell.tsx" }, "scripts": { "clean": "rm -rf .turbo node_modules", diff --git a/packages/widgets/src/modals/widget-edit-modal.module.css b/packages/widgets/src/modals/widget-edit-modal.module.css new file mode 100644 index 000000000..9294af70d --- /dev/null +++ b/packages/widgets/src/modals/widget-edit-modal.module.css @@ -0,0 +1,191 @@ +.workspace { + display: grid; + grid-template-columns: 1fr; + gap: var(--mantine-spacing-lg); + align-items: start; +} + +.workspace[data-with-preview][data-with-settings] { + grid-template-columns: minmax(0, 3fr) auto minmax(34rem, 2fr); +} + +.previewPanel { + position: sticky; + top: 0; + width: 100%; + max-width: 52rem; + min-width: 0; + justify-self: center; +} + +.previewDimensions { + position: absolute; + z-index: 2; + top: var(--mantine-spacing-xs); + right: var(--mantine-spacing-xs); + pointer-events: none; +} + +.previewSizeControls { + position: absolute; + z-index: 3; + top: var(--mantine-spacing-xs); + left: var(--mantine-spacing-xs); + padding: 0.25rem 0.375rem; + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-sm); + background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent); + box-shadow: var(--mantine-shadow-xs); + backdrop-filter: blur(8px); +} + +.previewSizeInput { + width: 5rem; + font-variant-numeric: tabular-nums; +} + +.previewCanvas { + position: relative; + width: 100%; + height: clamp(24rem, calc(100dvh - 17rem), 44rem); + padding: var(--mantine-spacing-lg); + overflow: hidden; + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8)); + background-image: + linear-gradient(to right, color-mix(in srgb, var(--mantine-color-dimmed) 14%, transparent) 1px, transparent 1px), + linear-gradient(to bottom, color-mix(in srgb, var(--mantine-color-dimmed) 14%, transparent) 1px, transparent 1px); + background-size: 2rem 2rem; +} + +.previewDivider { + height: auto; + align-self: stretch; +} + +.previewViewport { + position: relative; + flex: 0 0 auto; + animation: dock-widget 260ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.previewWidget { + position: absolute; + top: 0; + left: 0; + transform-origin: top left; +} + +.previewContent { + width: 100%; + height: 100%; + pointer-events: none; + user-select: none; +} + +.settingsPanel { + min-width: 0; + max-height: calc(100dvh - 13rem); + padding-right: 0.25rem; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.settingsForm { + min-width: 0; +} + +.fullWidthField { + grid-column: 1 / -1; +} + +.switchOption { + display: flex; + cursor: pointer; + height: 4.5rem; + align-items: center; + padding: 0.625rem 0.75rem; + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-sm); + background: var(--mantine-color-body); +} + +.switchOption > *, +.switchOption :global(.mantine-Switch-root), +.switchOption :global(.mantine-Switch-body) { + width: 100%; +} + +.switchOption :global(.mantine-Switch-body) { + flex-direction: row-reverse; + align-items: center; + justify-content: space-between; + gap: var(--mantine-spacing-md); +} + +.switchOption :global(.mantine-Switch-labelWrapper) { + min-width: 0; + flex: 1; +} + +.switchOption :global(.mantine-Switch-description) { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.footer { + position: sticky; + z-index: 3; + bottom: calc(var(--mantine-spacing-md) * -1); + margin-inline: calc(var(--mantine-spacing-md) * -1); + margin-bottom: calc(var(--mantine-spacing-md) * -1); + padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); + border-top: 1px solid var(--mantine-color-default-border); + background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent); + backdrop-filter: blur(10px); +} + +@keyframes dock-widget { + from { + opacity: 0; + transform: translate(-1.25rem, 1rem) scale(0.92); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (max-width: 68em) { + .workspace[data-with-preview][data-with-settings] { + grid-template-columns: 1fr; + } + + .previewPanel { + position: static; + max-width: 44rem; + } + + .previewDivider { + display: none; + } + + .previewCanvas { + height: 24rem; + } + + .settingsPanel { + max-height: none; + overflow: visible; + } +} + +@media (prefers-reduced-motion: reduce) { + .previewViewport { + animation: none; + } +} diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 61bfeac38..ea5d1b8e3 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -1,18 +1,38 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import type { ComponentType, FormEvent } from "react"; -import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core"; +/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */ + +import { useEffect, useId, useRef, useState } from "react"; +import type { ComponentType, FormEvent, PropsWithChildren } from "react"; +import { + Alert, + Badge, + Box, + Button, + Center, + Divider, + Group, + NumberInput, + SimpleGrid, + Stack, + Tabs, + Text, + Tooltip, +} from "@mantine/core"; import { schemaResolver } from "@mantine/form"; -import { IconArrowLeft, IconPencil } from "@tabler/icons-react"; +import { useElementSize } from "@mantine/hooks"; +import { IconArrowLeft, IconEye, IconPencil, IconSettings } from "@tabler/icons-react"; +import { QueryErrorResetBoundary } from "@tanstack/react-query"; +import { ErrorBoundary } from "react-error-boundary"; import { z } from "zod/v4"; import { objectEntries } from "@homarr/common"; -import { useSession } from "@homarr/auth/client"; +import { IntegrationProvider, useSession } from "@homarr/auth/client"; import { useOptionalBoard } from "@homarr/boards/context"; import type { WidgetKind } from "@homarr/definitions"; -import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals"; +import { createModal, modalSizeForm, useModalAction } from "@homarr/modals"; import type { SettingsContextProps } from "@homarr/settings/creator"; +import { SpotlightProvider } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; import { IntegrationAvatar } from "@homarr/ui"; import { zodErrorMap } from "@homarr/validation/form/i18n"; @@ -21,13 +41,16 @@ import { getInputForType } from "../_inputs"; import { FormProvider, useForm } from "../_inputs/form"; import type { BoardItemAdvancedOptions } from "../../../validation/src/shared"; import type { OptionsBuilderResult } from "../options"; -import type { WidgetDefinition } from "../definition"; +import type { WidgetComponentProps, WidgetDefinition } from "../definition"; +import { WidgetError } from "../errors/component"; import { OPTIONS_SUPER_REFINE } from "../options"; import type { IntegrationSelectOption } from "../widget-integration-select"; import { WidgetIntegrationSelect } from "../widget-integration-select"; +import { WidgetCardShell, WidgetTitleBadge } from "../widget-card-shell"; import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal"; import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form"; import { EmbeddedAppEditForm } from "./embedded-app-edit-form"; +import classes from "./widget-edit-modal.module.css"; export interface WidgetEditModalState { options: Record; @@ -35,6 +58,17 @@ export interface WidgetEditModalState { advancedOptions: BoardItemAdvancedOptions; } +export interface WidgetEditModalSize { + width: number; + height: number; +} + +interface WidgetEditPreviewResizeOptions { + initialSize: WidgetEditModalSize; + maximumSize: WidgetEditModalSize; + getDimensions?: (size: WidgetEditModalSize) => { width: number; height: number; scale?: number }; +} + export interface EmbeddedIntegrationEditFormHandle { submitIfDirty: () => Promise; } @@ -49,7 +83,7 @@ export interface WidgetEditModalProps { kind: TSort; definition: WidgetDefinition; value: WidgetEditModalState; - onSuccessfulEdit: (value: WidgetEditModalState) => void; + onSuccessfulEdit: (value: WidgetEditModalState, size?: WidgetEditModalSize) => void; integrationData: IntegrationSelectOption[]; integrationSupport: boolean; settings: SettingsContextProps; @@ -59,8 +93,215 @@ export interface WidgetEditModalProps { integrationEditForm?: ComponentType; onIntegrationSaved?: () => void; onOpenNewIntegration?: (onCreated?: (id: string) => void) => void; + previewComponent?: ComponentType>; + previewDimensions?: { width: number; height: number; scale?: number }; + previewResize?: WidgetEditPreviewResizeOptions; + previewWrapper?: ComponentType; } +interface WidgetEditPreviewProps { + kind: WidgetKind; + Component: ComponentType>; + definition: WidgetDefinition; + state: WidgetEditModalState; + itemId?: string; + boardId?: string; + dimensions?: { width: number; height: number; scale?: number }; + integrationData: IntegrationSelectOption[]; + onChangeOptions: (newOptions: Record) => void; + resize?: { + size: WidgetEditModalSize; + maximumSize: WidgetEditModalSize; + onChange: (size: WidgetEditModalSize) => void; + }; + PreviewWrapper?: ComponentType; +} + +const PreviewRuntimeBoundary = ({ + Wrapper, + children, +}: PropsWithChildren<{ Wrapper?: ComponentType }>) => { + if (!Wrapper) return children; + return {children}; +}; + +const normalizePreviewSize = (size: WidgetEditModalSize, maximumSize: WidgetEditModalSize): WidgetEditModalSize => ({ + width: Math.max(1, Math.min(Math.round(size.width), maximumSize.width)), + height: Math.max(1, Math.min(Math.round(size.height), maximumSize.height)), +}); + +const WidgetEditPreview = ({ + kind, + Component, + definition, + state, + itemId, + boardId, + dimensions = { width: 400, height: 240 }, + integrationData, + onChangeOptions, + resize, + PreviewWrapper, +}: WidgetEditPreviewProps) => { + const t = useI18n(); + const tItem = useI18n("item.edit"); + const board = useOptionalBoard(); + const generatedPreviewId = useId().replaceAll(":", ""); + const { ref, width: availableWidth, height: availableHeight } = useElementSize(); + const sourceWidth = Math.max(dimensions.width, 1); + const sourceHeight = Math.max(dimensions.height, 1); + let maximumScale = dimensions.scale ?? 0.9; + if (!Number.isFinite(maximumScale) || maximumScale <= 0) maximumScale = 0.9; + maximumScale = Math.min(maximumScale, 0.9); + let previewScale = maximumScale; + if (availableWidth > 0 && availableHeight > 0) { + previewScale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight, maximumScale); + } + const previewWidth = sourceWidth * previewScale; + const previewHeight = sourceHeight * previewScale; + + const hasIntegrationSupport = "supportedIntegrations" in definition; + const integrationRequired = hasIntegrationSupport && definition.integrationsRequired !== false; + const isMissingIntegration = integrationRequired && state.integrationIds.length === 0; + const previewIntegrations = integrationData + .filter((integration) => state.integrationIds.includes(integration.id)) + .map((integration) => ({ + id: integration.id, + permissions: integration.permissions ?? { + hasFullAccess: false, + hasInteractAccess: false, + hasUseAccess: false, + }, + })); + let componentItemId = itemId; + if (kind === "assistant") componentItemId = `widget-preview-${generatedPreviewId}`; + const isPendingCustomWidget = kind === "customApi" && !itemId; + const previewOpacity = (board?.opacity ?? 100) / 100; + const handleResizeValue = (dimension: keyof WidgetEditModalSize, value: string | number) => { + if (typeof value !== "number" || !Number.isFinite(value) || !resize) return; + resize.onChange( + normalizePreviewSize( + { + ...resize.size, + [dimension]: value, + }, + resize.maximumSize, + ), + ); + }; + + return ( + +
+ {resize && ( + + + {tItem("preview.size")} + + + handleResizeValue("width", value)} + min={1} + max={resize.maximumSize.width} + step={1} + allowDecimal={false} + allowNegative={false} + clampBehavior="strict" + size="xs" + leftSection={t("item.moveResize.field.width.shortLabel")} + leftSectionPointerEvents="none" + /> + + + × + + + handleResizeValue("height", value)} + min={1} + max={resize.maximumSize.height} + step={1} + allowDecimal={false} + allowNegative={false} + clampBehavior="strict" + size="xs" + leftSection={t("item.moveResize.field.height.shortLabel")} + leftSectionPointerEvents="none" + /> + + + )} + + {Math.round(sourceWidth)} × {Math.round(sourceHeight)} + + {isMissingIntegration ? ( + } maw={320}> + {tItem("preview.integrationRequired")} + + ) : ( + + + + + {({ reset }) => ( + ( + + )} + > + + + +
+ onChangeOptions(newOptions as Record)} + /> +
+
+
+
+
+ )} +
+
+
+ )} +
+
+ ); +}; + export const getSelectedWidgetIntegrations = ( integrationData: readonly IntegrationSelectOption[], selectedIds: readonly string[], @@ -81,6 +322,12 @@ export const WidgetEditModal = createModal>(({ const [mountedIntegrationIds, setMountedIntegrationIds] = useState([]); const [activeIntegrationId, setActiveIntegrationId] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [previewSize, setPreviewSize] = useState(() => { + const resize = innerProps.previewResize; + if (!resize) return null; + return normalizePreviewSize(resize.initialSize, resize.maximumSize); + }); + const widgetFormId = useId(); z.config({ customError: zodErrorMap(t), @@ -206,10 +453,13 @@ export const WidgetEditModal = createModal>(({ } } - innerProps.onSuccessfulEdit({ - ...values, - advancedOptions, - }); + innerProps.onSuccessfulEdit( + { + ...values, + advancedOptions, + }, + previewSize ?? undefined, + ); actions.closeModal(); } finally { setIsSubmitting(false); @@ -231,47 +481,149 @@ export const WidgetEditModal = createModal>(({ } : undefined; + const handlePreviewOptionsChange = (newOptions: Record) => { + form.setFieldValue("options", { + ...form.values.options, + ...newOptions, + }); + }; + + const selectedIntegrationKinds = innerProps.integrationData + .filter(({ id }) => form.values.integrationIds.includes(id)) + .map(({ kind }) => kind); + const visibleOptions = Object.entries(options).filter(([, value]) => { + const Input = getInputForType(value.type); + if (!Input) { + return false; + } + + return !value.shouldHide?.(form.values.options as never, selectedIntegrationKinds); + }); + const switchOptions = visibleOptions.filter(([, value]) => value.type === "switch"); + const otherOptions = visibleOptions.filter(([, value]) => value.type !== "switch"); + const hasWidgetSettings = canConfigureWidget && (innerProps.integrationSupport || visibleOptions.length > 0); + const previewResize = innerProps.previewResize; + let previewDimensions = innerProps.previewDimensions; + let resizeControls: WidgetEditPreviewProps["resize"]; + if (previewSize && previewResize?.getDimensions) { + previewDimensions = previewResize.getDimensions(previewSize); + resizeControls = { + size: previewSize, + maximumSize: previewResize.maximumSize, + onChange: setPreviewSize, + }; + } + const widgetFormContent = ( - - {canConfigureWidget && innerProps.integrationSupport && ( - 1} - withAsterisk={integrationsRequired} - onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined} - {...form.getInputProps("integrationIds")} + + {innerProps.previewComponent && ( + )} - {canConfigureWidget && - Object.entries(options).map(([key, value]) => { - const Input = getInputForType(value.type); + {innerProps.previewComponent && hasWidgetSettings && ( + + )} + + + ); + + const footer = ( + + - ) : ( - - openModal({ - advancedOptions, - onSuccess: setAdvancedOptions, - }) - } - > - {tItem("advancedOptions.label")} - - } - /> - )} - + + + + + + ); return ( -
- + + {showResourceTabs ? ( - - - - {tItem("tab.widget")} - {showAppTab && {tItem("tab.app")}} - {showIntegrationTab && {tItem("tab.integration")}} - - - {widgetFormContent} + + + {tItem("tab.widget")} + {showAppTab && {tItem("tab.app")}} + {showIntegrationTab && {tItem("tab.integration")}} + + + {widgetFormContent} + + {showAppTab && ( + + {appId && } - {showAppTab && ( - - {appId && } - - )} - {showIntegrationTab && ( - - - {displayedIntegrationId === null && ( - <> - - {tItem("integration.description")} - - {selectedIntegrations.map((integration) => { - const canEdit = editableIntegrationIds.includes(integration.id); - return ( - - - - - - {integration.name} - - - {integration.url} - - - - {canEdit ? ( - - ) : ( - - {tItem("integration.fullAccessRequired")} + )} + {showIntegrationTab && ( + + + {displayedIntegrationId === null && ( + <> + + {tItem("integration.description")} + + {selectedIntegrations.map((integration) => { + const canEdit = editableIntegrationIds.includes(integration.id); + return ( + + + + + + {integration.name} - )} + + {integration.url} + + - ); - })} - - )} - {displayedIntegrationId !== null && selectedIntegrations.length > 1 && ( - - )} - {integrationIdsToRender.map((integrationId) => ( - - ))} - - - )} - - - - - - - - + {canEdit ? ( + + ) : ( + + {tItem("integration.fullAccessRequired")} + + )} + + ); + })} + + )} + {displayedIntegrationId !== null && selectedIntegrations.length > 1 && ( + + )} + {integrationIdsToRender.map((integrationId) => ( + + ))} + + + )} + ) : ( widgetFormContent )} - - + {footer} + +
); }).withOptions({ keepMounted: true, diff --git a/packages/widgets/src/widget-card-shell.module.css b/packages/widgets/src/widget-card-shell.module.css new file mode 100644 index 000000000..1724e5147 --- /dev/null +++ b/packages/widgets/src/widget-card-shell.module.css @@ -0,0 +1,34 @@ +.card { + @mixin dark { + --background-color: rgb(from var(--mantine-color-dark-6) r g b / var(--opacity)); + --default-border-color: rgb(from var(--mantine-color-dark-4) r g b / var(--opacity)); + } + + @mixin light { + --background-color: rgb(from var(--mantine-color-white) r g b / var(--opacity)); + --default-border-color: rgb(from var(--mantine-color-gray-3) r g b / var(--opacity)); + } + + background-color: var(--background-color) !important; + border-color: var(--border-color, var(--default-border-color)) !important; +} + +.card.iframe { + background-color: transparent !important; + border-color: transparent !important; +} + +.title { + @mixin dark { + --background-color: rgb(from var(--mantine-color-dark-6) r g b / var(--opacity)); + --default-border-color: rgb(from var(--mantine-color-dark-4) r g b / var(--opacity)); + } + + @mixin light { + --background-color: rgb(from var(--mantine-color-white) r g b / var(--opacity)); + --default-border-color: rgb(from var(--mantine-color-gray-3) r g b / var(--opacity)); + } + + background-color: var(--background-color) !important; + border-color: var(--border-color, var(--default-border-color)) !important; +} diff --git a/packages/widgets/src/widget-card-shell.tsx b/packages/widgets/src/widget-card-shell.tsx new file mode 100644 index 000000000..a8843c5c4 --- /dev/null +++ b/packages/widgets/src/widget-card-shell.tsx @@ -0,0 +1,95 @@ +"use client"; + +import type { AriaRole, CSSProperties, PropsWithChildren, Ref } from "react"; +import type { CardProps } from "@mantine/core"; +import { Badge, Card } from "@mantine/core"; +import combineClasses from "clsx"; + +import type { WidgetKind } from "@homarr/definitions"; +import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; + +import classes from "./widget-card-shell.module.css"; + +const getOverflow = (kind: WidgetKind, hasCustomCssClasses: boolean): CSSProperties => { + if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" }; + if (kind === "iframe" || kind === "assistant") return { overflowX: "hidden", overflowY: "hidden" }; + if (hasCustomCssClasses) return {}; + return { overflowX: "hidden", overflowY: "auto" }; +}; + +interface WidgetCardShellProps extends PropsWithChildren> { + kind: WidgetKind; + advancedOptions: BoardItemAdvancedOptions; + opacity: number; + className?: string; + innerRef?: Ref; + id?: string; + role?: AriaRole; + "aria-label"?: string; + "data-advanced-focus-surface"?: boolean; + "data-grid-item-content"?: boolean; +} + +export const WidgetCardShell = ({ + kind, + advancedOptions, + opacity, + className, + innerRef, + children, + ...cardProps +}: WidgetCardShellProps) => ( + 0), + } as CSSProperties, + }} + > + {children} + +); + +interface WidgetTitleBadgeProps { + advancedOptions: BoardItemAdvancedOptions; + opacity: number; + radius: CardProps["radius"]; +} + +export const WidgetTitleBadge = ({ advancedOptions, opacity, radius }: WidgetTitleBadgeProps) => { + if (!advancedOptions.title?.trim()) return null; + + return ( + + {advancedOptions.title} + + ); +};