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:
- 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 (
-
+ {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}
+
+ );
+};