diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index dcd800dc8..da1701e8e 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -194,11 +194,15 @@ To configure this, click on the small 3 dots at the top of the item in the edit
The tile menu contains the item editor and the separate move-and-resize tools.
-In the modal you can edit the app that you want to display: +The side editor keeps the selected tile visible while you choose the app and adjust its display settings:
- Current Edit item modal for an App tile -
Use the Edit item modal to choose the app and configure how its tile is displayed.
+ App tile highlighted beside the Edit item side editor +
The selected tile stays highlighted while the rest of the board is subdued.
As soon as you save your changes, the tile on the board will refresh and display your app. 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..795c7b5d9 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/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 2b3a79dcc..6b6f2ce06 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -3,6 +3,7 @@ import { ActionIcon, Menu } from "@mantine/core"; import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react"; import { useSession } from "@homarr/auth/client"; +import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { getWidgetName } from "@homarr/definitions"; import { useModalAction } from "@homarr/modals"; @@ -33,6 +34,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => { }; const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => { const { data: session } = useSession(); + const board = useRequiredBoard(); const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); const refResetErrorBoundaryOnNextRender = useRef(false); const tItem = useI18n("item"); @@ -89,6 +91,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM integrationSupport: "supportedIntegrations" in definition, settings, itemId: item.id, + boardId: board.id, 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..6c9a98362 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -277,6 +277,7 @@ const ItemSelectModalContent = ({ integrationData: [], integrationSupport: false, settings, + boardId: board.id, }, { title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`, @@ -346,6 +347,7 @@ const ItemSelectModalContent = ({ integrationData: availableIntegrations, integrationSupport: hasIntegrationSupport, settings, + boardId: board.id, }, { 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..a1941ab4e 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 @@ -157,6 +157,8 @@ export const LazyWidgetEditModal = createModal> defaultTitle(t) { return t("item.edit.title"); }, + presentation: "inspector", + withOverlay: false, size: modalSizeForm, 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..1767298ff 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -160,7 +160,7 @@ export const WidgetContextMenu = ({ definition, value: { advancedOptions: item.advancedOptions, - options: item.options, + options, integrationIds: item.integrationIds, }, onSuccessfulEdit: (editResult) => { @@ -189,6 +189,8 @@ export const WidgetContextMenu = ({ ), integrationSupport: definition.supportedIntegrations !== undefined, settings, + itemId: item.id, + boardId: board.id, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -197,11 +199,13 @@ export const WidgetContextMenu = ({ }, ); }, [ + board.id, definition, integrationData, item, handleRefetch, openModal, + options, persistBoard, settings, updateItemAdvancedOptions, diff --git a/packages/modals/src/modal-presentation.module.css b/packages/modals/src/modal-presentation.module.css index 22949cd8d..ceb9df7fd 100644 --- a/packages/modals/src/modal-presentation.module.css +++ b/packages/modals/src/modal-presentation.module.css @@ -7,9 +7,11 @@ .inspectorContent { --modal-content-flex: 0 0 100%; --modal-content-max-height: min(88dvh, 52rem); + --modal-size: 100%; width: 100%; max-width: 100%; + min-width: 0; border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0; } @@ -23,8 +25,13 @@ --modal-content-flex: 0 0 min(34rem, 46vw); --modal-content-height: 100dvh; --modal-content-max-height: 100dvh; + --modal-size: min(34rem, 46vw); width: min(34rem, 46vw); border-radius: 0; } + + .inspectorInner:has([data-widget-inspector-side="left"]) { + justify-content: flex-start; + } } diff --git a/packages/modals/src/type.ts b/packages/modals/src/type.ts index 5faa421d2..30df9ba4d 100644 --- a/packages/modals/src/type.ts +++ b/packages/modals/src/type.ts @@ -19,6 +19,7 @@ export type CreateModalOptions = Pick< | "centered" | "keepMounted" | "withCloseButton" + | "withOverlay" | "zIndex" | "scrollAreaComponent" | "yOffset" diff --git a/packages/widgets/src/modals/widget-advanced-options-modal.tsx b/packages/widgets/src/modals/widget-advanced-options-modal.tsx index 184fd4678..c148f8b88 100644 --- a/packages/widgets/src/modals/widget-advanced-options-modal.tsx +++ b/packages/widgets/src/modals/widget-advanced-options-modal.tsx @@ -17,7 +17,10 @@ export const WidgetAdvancedOptionsModal = createModal(({ actions, in const t = useI18n("item.edit"); const theme = useMantineTheme(); const form = useForm({ - initialValues: innerProps.advancedOptions, + initialValues: { + ...innerProps.advancedOptions, + title: innerProps.advancedOptions.title ?? "", + }, }); const handleSubmit = (values: BoardItemAdvancedOptions) => { innerProps.onSuccess({ diff --git a/packages/widgets/src/modals/widget-edit-focus-overlay.tsx b/packages/widgets/src/modals/widget-edit-focus-overlay.tsx new file mode 100644 index 000000000..77fec2961 --- /dev/null +++ b/packages/widgets/src/modals/widget-edit-focus-overlay.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; +import { Box, getDefaultZIndex, Overlay, Portal } from "@mantine/core"; +import { useIsomorphicEffect } from "@mantine/hooks"; + +import classes from "./widget-edit-modal.module.css"; + +interface FocusRect { + top: number; + left: number; + width: number; + height: number; +} + +export type WidgetInspectorSide = "left" | "right"; + +const focusGap = 6; + +const getFocusRect = (element: HTMLElement): FocusRect | null => { + const rect = element.getBoundingClientRect(); + const top = Math.max(4, rect.top - focusGap); + const left = Math.max(4, rect.left - focusGap); + const right = Math.min(window.innerWidth - 4, rect.right + focusGap); + const bottom = Math.min(window.innerHeight - 4, rect.bottom + focusGap); + + if (right <= left || bottom <= top) return null; + + return { + top, + left, + width: right - left, + height: bottom - top, + }; +}; + +const focusRectsMatch = (current: FocusRect | null, next: FocusRect | null) => { + if (current === next) return true; + if (!current || !next) return false; + + return ( + current.top === next.top && + current.left === next.left && + current.width === next.width && + current.height === next.height + ); +}; + +export const useWidgetEditFocus = (itemId: string | undefined) => { + const [focusRect, setFocusRect] = useState(null); + + useIsomorphicEffect(() => { + if (!itemId) { + setFocusRect(null); + return; + } + + const element = document.querySelector(`[data-item-id="${CSS.escape(itemId)}"]`); + if (!element) { + setFocusRect(null); + return; + } + + let animationFrame = 0; + const update = () => { + animationFrame = 0; + const nextRect = getFocusRect(element); + setFocusRect((current) => (focusRectsMatch(current, nextRect) ? current : nextRect)); + }; + const scheduleUpdate = () => { + if (animationFrame !== 0) return; + animationFrame = window.requestAnimationFrame(update); + }; + + update(); + const resizeObserver = new ResizeObserver(scheduleUpdate); + resizeObserver.observe(element); + window.addEventListener("resize", scheduleUpdate); + window.addEventListener("scroll", scheduleUpdate, true); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener("resize", scheduleUpdate); + window.removeEventListener("scroll", scheduleUpdate, true); + window.cancelAnimationFrame(animationFrame); + }; + }, [itemId]); + + const inspectorSide: WidgetInspectorSide = + focusRect && focusRect.left + focusRect.width / 2 > window.innerWidth / 2 ? "left" : "right"; + + return { focusRect, inspectorSide }; +}; + +export const WidgetEditFocusOverlay = ({ focusRect }: { focusRect: FocusRect | null }) => ( + + {focusRect ? ( + + ) : ( + + )} + +); 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..65f0bf333 --- /dev/null +++ b/packages/widgets/src/modals/widget-edit-modal.module.css @@ -0,0 +1,74 @@ +.panel { + min-width: 0; +} + +.settings { + min-width: 0; +} + +.switchGrid { + align-items: stretch; +} + +.switchOption { + display: flex; + min-height: 4.25rem; + 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-sm); +} + +.switchOption :global(.mantine-Switch-labelWrapper) { + min-width: 0; + flex: 1; +} + +.footer { + position: sticky; + z-index: 2; + 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) 96%, transparent); + backdrop-filter: blur(10px); +} + +.focusFrame { + position: fixed; + pointer-events: none; + border: 2px solid var(--mantine-primary-color-filled); + border-radius: var(--mantine-radius-md); + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent), + 0 0 2.5rem color-mix(in srgb, var(--mantine-primary-color-filled) 28%, transparent), + 0 0 0 100vmax rgb(0 0 0 / 0.58); + clip-path: inset(-100vmax); + transition: + top 160ms ease, + left 160ms ease, + width 160ms ease, + height 160ms ease; +} + +@media (prefers-reduced-motion: reduce) { + .focusFrame { + transition: none; + } +} diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 61bfeac38..0833612fb 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import type { ComponentType, FormEvent } from "react"; -import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core"; +import { Box, Button, Group, SimpleGrid, Stack, Tabs, Text } from "@mantine/core"; import { schemaResolver } from "@mantine/form"; import { IconArrowLeft, IconPencil } from "@tabler/icons-react"; import { z } from "zod/v4"; @@ -11,7 +11,7 @@ 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, modalSizeForm, useModalAction } from "@homarr/modals"; import type { SettingsContextProps } from "@homarr/settings/creator"; import { useI18n } from "@homarr/translation/client"; import { IntegrationAvatar } from "@homarr/ui"; @@ -26,8 +26,10 @@ 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 { useWidgetEditFocus, WidgetEditFocusOverlay } from "./widget-edit-focus-overlay"; 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; @@ -81,6 +83,8 @@ export const WidgetEditModal = createModal>(({ const [mountedIntegrationIds, setMountedIntegrationIds] = useState([]); const [activeIntegrationId, setActiveIntegrationId] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const widgetFormId = useId(); + const { focusRect, inspectorSide } = useWidgetEditFocus(innerProps.itemId); z.config({ customError: zodErrorMap(t), @@ -231,63 +235,167 @@ export const WidgetEditModal = createModal>(({ } : undefined; + 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")} - /> - )} - {canConfigureWidget && - Object.entries(options).map(([key, value]) => { - const Input = getInputForType(value.type); +
+ + {canConfigureWidget && innerProps.integrationSupport && ( + 1} + withAsterisk={integrationsRequired} + onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined} + {...form.getInputProps("integrationIds")} + /> + )} + {canConfigureWidget && switchOptions.length > 0 && ( + + {switchOptions.map(([key, value]) => { + const Input = getInputForType(value.type); + if (!Input) return null; - if ( - !Input || - value.shouldHide?.( - form.values.options as never, - innerProps.integrationData - .filter(({ id }) => form.values.integrationIds.includes(id)) - .map(({ kind }) => kind), - ) - ) { - return null; - } + return ( + + + + ); + })} + + )} + {canConfigureWidget && + otherOptions.map(([key, value]) => { + const Input = getInputForType(value.type); + if (!Input) return null; - return ( - - ); - })} - {showResourceTabs ? ( - - ) : ( - + ); + })} + +
+ ); + + return ( + <> + + + + + {showResourceTabs ? ( + + + {tItem("tab.widget")} + {showAppTab && {tItem("tab.app")}} + {showIntegrationTab && {tItem("tab.integration")}} + + + {widgetFormContent} + + {showAppTab && ( + + {appId && } + + )} + {showIntegrationTab && ( + + + {displayedIntegrationId === null && ( + <> + + {tItem("integration.description")} + + {selectedIntegrations.map((integration) => { + const canEdit = editableIntegrationIds.includes(integration.id); + return ( + + + + + + {integration.name} + + + {integration.url} + + + + {canEdit ? ( + + ) : ( + + {tItem("integration.fullAccessRequired")} + + )} + + ); + })} + + )} + {displayedIntegrationId !== null && selectedIntegrations.length > 1 && ( + + )} + {integrationIdsToRender.map((integrationId) => ( + + ))} + + + )} + + ) : ( + widgetFormContent + )} + + - } - /> - )} - - ); - - return ( -
- - {showResourceTabs ? ( - - - - {tItem("tab.widget")} - {showAppTab && {tItem("tab.app")}} - {showIntegrationTab && {tItem("tab.integration")}} - - - {widgetFormContent} - - {showAppTab && ( - - {appId && } - - )} - {showIntegrationTab && ( - - - {displayedIntegrationId === null && ( - <> - - {tItem("integration.description")} - - {selectedIntegrations.map((integration) => { - const canEdit = editableIntegrationIds.includes(integration.id); - return ( - - - - - - {integration.name} - - - {integration.url} - - - - {canEdit ? ( - - ) : ( - - {tItem("integration.fullAccessRequired")} - - )} - - ); - })} - - )} - {displayedIntegrationId !== null && selectedIntegrations.length > 1 && ( - - )} - {integrationIdsToRender.map((integrationId) => ( - - ))} - - - )} - - - - - - - - - ) : ( - widgetFormContent - )} + + + + + + -
+ ); }).withOptions({ keepMounted: true, defaultTitle(t) { return t("item.edit.title"); }, + presentation: "inspector", + withOverlay: false, size: modalSizeForm, closeOnClickOutside: false, });