From 5188ed89d698f4f88827be1fc580196c112d0c37 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 22 Aug 2026 15:59:58 +0200 Subject: [PATCH] feat: rework widget settings modal --- .../after-the-installation.mdx | 4 +- .../app/[locale]/widgets/[kind]/_content.tsx | 7 +- .../src/components/board/items/item-menu.tsx | 1 + .../board/items/lazy-widget-edit-modal.tsx | 11 +- .../board/items/widget-context-menu.tsx | 4 + packages/translation/src/lang/en.json | 6 + .../src/modals/widget-edit-modal.module.css | 160 +++++++++ .../widgets/src/modals/widget-edit-modal.tsx | 323 +++++++++++++----- 8 files changed, 419 insertions(+), 97 deletions(-) create mode 100644 packages/widgets/src/modals/widget-edit-modal.module.css diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index dcd800dc8..4eeeb7946 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -198,7 +198,9 @@ 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. +
As soon as you save your changes, the tile on the board will refresh and display your app. 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/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 2b3a79dcc..7d67d51bc 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -89,6 +89,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM integrationSupport: "supportedIntegrations" in definition, settings, itemId: item.id, + previewDimensions: { width: item.width * 200, height: item.height * 200 }, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, { 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..48af5be5f 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 @@ -7,10 +7,11 @@ 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 { createModal, modalSizeSelect, 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 { loadWidgetComponent } from "@homarr/widgets/manifest"; import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select"; import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; @@ -83,6 +84,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(); @@ -122,6 +124,7 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { ...props.innerProps, integrationData: combinedIntegrationData, integrationEditForm: LazyIntegrationEditForm, + previewComponent: PreviewComponent, onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined, }} /> @@ -157,6 +160,10 @@ export const LazyWidgetEditModal = createModal> defaultTitle(t) { return t("item.edit.title"); }, - size: modalSizeForm, + size: modalSizeSelect, + 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..52dfbb3a1 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -189,6 +189,9 @@ export const WidgetContextMenu = ({ ), integrationSupport: definition.supportedIntegrations !== undefined, settings, + itemId: item.id, + boardId: board.id, + previewDimensions: { width: item.width * 200, height: item.height * 200 }, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -197,6 +200,7 @@ export const WidgetContextMenu = ({ }, ); }, [ + board.id, definition, integrationData, item, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index dc27c91c8..0ec728a56 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2115,6 +2115,12 @@ "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." + }, + "settings": { + "label": "Widget settings" + }, "advancedOptions": { "label": "Advanced options", "title": "Advanced item options" 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..eb9559b10 --- /dev/null +++ b/packages/widgets/src/modals/widget-edit-modal.module.css @@ -0,0 +1,160 @@ +.workspace { + display: grid; + grid-template-columns: 1fr; + gap: var(--mantine-spacing-lg); + align-items: start; +} + +.workspace[data-with-preview] { + grid-template-columns: minmax(0, 0.95fr) minmax(24rem, 1.05fr); +} + +.previewPanel { + position: sticky; + top: 0; + min-width: 0; + padding: var(--mantine-spacing-md); + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); + background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7)); +} + +.previewDimensions { + position: absolute; + z-index: 2; + top: calc(var(--mantine-spacing-md) + var(--mantine-spacing-xs)); + right: calc(var(--mantine-spacing-md) + var(--mantine-spacing-xs)); + pointer-events: none; +} + +.previewCanvas { + min-height: 23rem; + 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; +} + +.previewWidget { + position: relative; + flex: 0 0 auto; + overflow: hidden; + box-shadow: var(--mantine-shadow-lg); + animation: dock-widget 260ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.previewTitle { + position: absolute; + z-index: 2; + top: var(--mantine-spacing-xs); + left: var(--mantine-spacing-xs); + max-width: calc(100% - var(--mantine-spacing-md)); +} + +.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; +} + +.fullWidthField { + grid-column: 1 / -1; +} + +.switchOption { + display: flex; + 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: 62em) { + .workspace[data-with-preview] { + grid-template-columns: 1fr; + } + + .previewPanel { + position: static; + } + + .previewCanvas { + min-height: 18rem; + } + + .settingsPanel { + max-height: none; + overflow: visible; + } +} + +@media (prefers-reduced-motion: reduce) { + .previewWidget { + animation: none; + } +} diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 61bfeac38..4c9ccaa90 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -1,17 +1,22 @@ "use client"; +/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */ + import { useEffect, useRef, useState } from "react"; import type { ComponentType, FormEvent } from "react"; -import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core"; +import { Alert, Badge, Box, Button, Card, Center, Group, SimpleGrid, Stack, Tabs, Text } 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 { useOptionalBoard } from "@homarr/boards/context"; import type { WidgetKind } from "@homarr/definitions"; -import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals"; +import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import type { SettingsContextProps } from "@homarr/settings/creator"; import { useI18n } from "@homarr/translation/client"; import { IntegrationAvatar } from "@homarr/ui"; @@ -21,13 +26,15 @@ 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 { 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; @@ -59,8 +66,101 @@ export interface WidgetEditModalProps { integrationEditForm?: ComponentType; onIntegrationSaved?: () => void; onOpenNewIntegration?: (onCreated?: (id: string) => void) => void; + previewComponent?: ComponentType>; + previewDimensions?: { width: number; height: number }; } +interface WidgetEditPreviewProps { + Component: ComponentType>; + definition: WidgetDefinition; + state: WidgetEditModalState; + itemId?: string; + boardId?: string; + dimensions?: { width: number; height: number }; + onChangeOptions: (newOptions: Record) => void; +} + +const WidgetEditPreview = ({ + Component, + definition, + state, + itemId, + boardId, + dimensions = { width: 400, height: 240 }, + onChangeOptions, +}: WidgetEditPreviewProps) => { + const tItem = useI18n("item.edit"); + const { ref, width: availableWidth } = useElementSize(); + const sourceWidth = Math.max(dimensions.width, 1); + const sourceHeight = Math.max(dimensions.height, 1); + const aspectRatio = sourceWidth / sourceHeight; + const maximumWidth = Math.max(availableWidth, 280); + let previewWidth = maximumWidth; + let previewHeight = previewWidth / aspectRatio; + + if (previewHeight > 320) { + previewHeight = 320; + previewWidth = previewHeight * aspectRatio; + } + + const hasIntegrationSupport = "supportedIntegrations" in definition; + const integrationRequired = hasIntegrationSupport && definition.integrationsRequired !== false; + const isMissingIntegration = integrationRequired && state.integrationIds.length === 0; + + return ( + + + {Math.round(sourceWidth)} × {Math.round(sourceHeight)} + +
+ {isMissingIntegration ? ( + } maw={320}> + {tItem("preview.integrationRequired")} + + ) : ( + = 96 ? undefined : 4} + style={{ borderColor: state.advancedOptions.borderColor || undefined }} + > + {state.advancedOptions.title && ( + + {state.advancedOptions.title} + + )} + + {({ reset }) => ( + ( + + )} + > +
+ onChangeOptions(newOptions as Record)} + /> +
+
+ )} +
+
+ )} +
+
+ ); +}; + export const getSelectedWidgetIntegrations = ( integrationData: readonly IntegrationSelectOption[], selectedIds: readonly string[], @@ -231,47 +331,112 @@ 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 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); - - if ( - !Input || - value.shouldHide?.( - form.values.options as never, - innerProps.integrationData - .filter(({ id }) => form.values.integrationIds.includes(id)) - .map(({ kind }) => kind), - ) - ) { - return null; - } - - return ( - + + + + {tItem("settings.label")} + + + {canConfigureWidget && innerProps.integrationSupport && ( + + 1} + withAsterisk={integrationsRequired} + onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined} + {...form.getInputProps("integrationIds")} /> - ); - })} - {showResourceTabs ? ( + + )} + {canConfigureWidget && ( + + {switchOptions.length > 0 && ( + + {switchOptions.map(([key, value]) => { + const Input = getInputForType(value.type); + if (!Input) { + return null; + } + + return ( + + + + ); + })} + + )} + {otherOptions.map(([key, value]) => { + const Input = getInputForType(value.type); + if (!Input) { + return null; + } + + return ( + + + + ); + })} + + )} + + + ); + + const footer = ( + + - ) : ( - - openModal({ - advancedOptions, - onSuccess: setAdvancedOptions, - }) - } - > - {tItem("advancedOptions.label")} - - } - /> - )} - + + + + + + ); return (
- {showResourceTabs ? ( - + + {showResourceTabs ? ( {tItem("tab.widget")} {showAppTab && {tItem("tab.app")}} {showIntegrationTab && {tItem("tab.integration")}} - + {widgetFormContent} {showAppTab && ( - + {appId && } )} {showIntegrationTab && ( - + {displayedIntegrationId === null && ( <> @@ -394,31 +549,11 @@ export const WidgetEditModal = createModal>(({ )} - - - - - - - - ) : ( - widgetFormContent - )} + ) : ( + widgetFormContent + )} + {footer} +
); @@ -427,6 +562,10 @@ export const WidgetEditModal = createModal>(({ defaultTitle(t) { return t("item.edit.title"); }, - size: modalSizeForm, + size: modalSizeSelect, + transitionProps: { + transition: "pop", + duration: 180, + }, closeOnClickOutside: false, });