diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index 4eeeb7946..6fbb301d0 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -203,6 +203,8 @@ In the modal you can edit the app that you want to display: +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/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 53d601561..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 @@ -12,7 +12,7 @@ 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"; @@ -26,6 +26,10 @@ 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(); @@ -153,6 +157,10 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { height: getLogicalTrackSize(size.height), }; }, [props.innerProps.kind, props.innerProps.previewDimensions]); + let previewResize = props.innerProps.previewResize; + if (previewResize) { + previewResize = { ...previewResize, getDimensions: getPreviewDimensions }; + } return ( { integrationEditForm: LazyIntegrationEditForm, previewComponent: PreviewComponent, previewDimensions, + previewResize, previewWrapper: PreviewRuntimeWrapper, onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined, }} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 0ec728a56..bba46c6e7 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2116,7 +2116,8 @@ "loadError": "This integration could not be loaded. Try again before changing its connection settings." }, "preview": { - "integrationRequired": "Choose a connection to preview this widget." + "integrationRequired": "Choose a connection to preview this widget.", + "size": "Size" }, "settings": { "label": "Widget settings" diff --git a/packages/widgets/src/modals/widget-edit-modal.module.css b/packages/widgets/src/modals/widget-edit-modal.module.css index 7c6793542..6dcc2d579 100644 --- a/packages/widgets/src/modals/widget-edit-modal.module.css +++ b/packages/widgets/src/modals/widget-edit-modal.module.css @@ -26,6 +26,24 @@ 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%; diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index d45fc2f60..98d52c1eb 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -4,7 +4,21 @@ import { useEffect, useId, useRef, useState } from "react"; import type { ComponentType, FormEvent, PropsWithChildren } from "react"; -import { Alert, Badge, Box, Button, Center, Divider, Group, SimpleGrid, Stack, Tabs, Text } from "@mantine/core"; +import { + Alert, + Badge, + Box, + Button, + Center, + Divider, + Group, + NumberInput, + SimpleGrid, + Stack, + Tabs, + Text, + Tooltip, +} from "@mantine/core"; import { schemaResolver } from "@mantine/form"; import { useElementSize } from "@mantine/hooks"; import { IconArrowLeft, IconEye, IconPencil, IconSettings } from "@tabler/icons-react"; @@ -44,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; } @@ -58,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; @@ -70,6 +95,7 @@ export interface WidgetEditModalProps { onOpenNewIntegration?: (onCreated?: (id: string) => void) => void; previewComponent?: ComponentType>; previewDimensions?: { width: number; height: number; scale?: number }; + previewResize?: WidgetEditPreviewResizeOptions; previewWrapper?: ComponentType; } @@ -83,6 +109,11 @@ interface WidgetEditPreviewProps { dimensions?: { width: number; height: number; scale?: number }; integrationData: IntegrationSelectOption[]; onChangeOptions: (newOptions: Record) => void; + resize?: { + size: WidgetEditModalSize; + maximumSize: WidgetEditModalSize; + onChange: (size: WidgetEditModalSize) => void; + }; PreviewWrapper?: ComponentType; } @@ -94,6 +125,11 @@ const PreviewRuntimeBoundary = ({ 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, @@ -104,8 +140,10 @@ const WidgetEditPreview = ({ 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(":", ""); @@ -139,10 +177,66 @@ const WidgetEditPreview = ({ 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="W" + leftSectionPointerEvents="none" + /> + + + × + + + handleResizeValue("height", value)} + min={1} + max={resize.maximumSize.height} + step={1} + allowDecimal={false} + allowNegative={false} + clampBehavior="strict" + size="xs" + leftSection="H" + leftSectionPointerEvents="none" + /> + + + )} {Math.round(sourceWidth)} × {Math.round(sourceHeight)} @@ -228,6 +322,11 @@ 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({ @@ -354,10 +453,13 @@ export const WidgetEditModal = createModal>(({ } } - innerProps.onSuccessfulEdit({ - ...values, - advancedOptions, - }); + innerProps.onSuccessfulEdit( + { + ...values, + advancedOptions, + }, + previewSize ?? undefined, + ); actions.closeModal(); } finally { setIsSubmitting(false); @@ -400,6 +502,17 @@ export const WidgetEditModal = createModal>(({ 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 = ( >(({ state={{ ...form.values, advancedOptions }} itemId={innerProps.itemId} boardId={innerProps.boardId ?? board?.id} - dimensions={innerProps.previewDimensions} + dimensions={previewDimensions} integrationData={innerProps.integrationData} onChangeOptions={handlePreviewOptionsChange} + resize={resizeControls} PreviewWrapper={innerProps.previewWrapper} /> )}