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]/widgets/[kind]/page.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx index 620a7792e..36cf7edf9 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx @@ -1,11 +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"; @@ -19,15 +22,24 @@ export default async function WidgetPreview(props: Props) { if (!widgetKinds.includes(kind as WidgetKind)) { notFound(); } - const integrations = await getIntegrationsWithPermissionsAsync(await auth()); + 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 4a771e769..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,6 +23,7 @@ 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"; @@ -35,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"; @@ -49,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} - + ); }; @@ -297,8 +277,11 @@ const LoadedBoardItemContent = ({ : undefined; const widgetCard = ( - 0), - "--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined, - }, - }} style={previewStyle} > @@ -351,7 +322,7 @@ const LoadedBoardItemContent = ({ /> - + ); return ( @@ -425,25 +396,12 @@ const LoadedBoardItemContent = ({ )} - {!isAdvanced && item.advancedOptions.title?.trim() && ( - - {item.advancedOptions.title} - + /> )} ); 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 0e869a544..53d601561 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,6 +8,7 @@ import type { FallbackProps } from "react-error-boundary"; import { clientApi } from "@homarr/api/client"; import type { WidgetKind } from "@homarr/definitions"; +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"; @@ -14,10 +16,37 @@ import type { WidgetEditModalProps } 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 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 = () => { @@ -116,6 +145,14 @@ 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]); return ( { integrationData: combinedIntegrationData, integrationEditForm: LazyIntegrationEditForm, previewComponent: PreviewComponent, + previewDimensions, + previewWrapper: PreviewRuntimeWrapper, onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined, }} /> 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/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 index 25afd728d..7c6793542 100644 --- a/packages/widgets/src/modals/widget-edit-modal.module.css +++ b/packages/widgets/src/modals/widget-edit-modal.module.css @@ -56,19 +56,9 @@ position: absolute; top: 0; left: 0; - overflow: hidden; - box-shadow: var(--mantine-shadow-lg); transform-origin: top left; } -.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%; diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 8922f0d6a..d45fc2f60 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -3,13 +3,12 @@ /* 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 } from "react"; -import { Alert, Badge, Box, Button, Card, Center, Divider, Group, SimpleGrid, Stack, Tabs, Text } from "@mantine/core"; +import type { ComponentType, FormEvent, PropsWithChildren } from "react"; +import { Alert, Badge, Box, Button, Center, Divider, Group, SimpleGrid, Stack, Tabs, Text } from "@mantine/core"; import { schemaResolver } from "@mantine/form"; import { useElementSize } from "@mantine/hooks"; import { IconArrowLeft, IconEye, IconPencil, IconSettings } from "@tabler/icons-react"; import { QueryErrorResetBoundary } from "@tanstack/react-query"; -import combineClasses from "clsx"; import { ErrorBoundary } from "react-error-boundary"; import { z } from "zod/v4"; @@ -17,8 +16,9 @@ import { objectEntries } from "@homarr/common"; import { IntegrationProvider, useSession } from "@homarr/auth/client"; import { useOptionalBoard } from "@homarr/boards/context"; import type { WidgetKind } from "@homarr/definitions"; -import { createModal, 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"; @@ -32,6 +32,7 @@ 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"; @@ -69,6 +70,7 @@ export interface WidgetEditModalProps { onOpenNewIntegration?: (onCreated?: (id: string) => void) => void; previewComponent?: ComponentType>; previewDimensions?: { width: number; height: number; scale?: number }; + previewWrapper?: ComponentType; } interface WidgetEditPreviewProps { @@ -81,8 +83,17 @@ interface WidgetEditPreviewProps { dimensions?: { width: number; height: number; scale?: number }; integrationData: IntegrationSelectOption[]; onChangeOptions: (newOptions: Record) => void; + PreviewWrapper?: ComponentType; } +const PreviewRuntimeBoundary = ({ + Wrapper, + children, +}: PropsWithChildren<{ Wrapper?: ComponentType }>) => { + if (!Wrapper) return children; + return {children}; +}; + const WidgetEditPreview = ({ kind, Component, @@ -93,8 +104,11 @@ const WidgetEditPreview = ({ dimensions = { width: 400, height: 240 }, integrationData, onChangeOptions, + PreviewWrapper, }: WidgetEditPreviewProps) => { 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); @@ -111,14 +125,20 @@ const WidgetEditPreview = ({ const hasIntegrationSupport = "supportedIntegrations" in definition; const integrationRequired = hasIntegrationSupport && definition.integrationsRequired !== false; const isMissingIntegration = integrationRequired && state.integrationIds.length === 0; - const previewIntegrations = integrationData.map((integration) => ({ - id: integration.id, - permissions: integration.permissions ?? { - hasFullAccess: false, - hasInteractAccess: false, - hasUseAccess: false, - }, - })); + 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; return ( @@ -132,27 +152,23 @@ const WidgetEditPreview = ({ ) : ( - - {state.advancedOptions.title && ( - - {state.advancedOptions.title} - - )} + {({ reset }) => ( )} > - -
- onChangeOptions(newOptions as Record)} - /> -
-
+ + + +
+ onChangeOptions(newOptions as Record)} + /> +
+
+
+
)}
-
+
)} @@ -398,6 +418,7 @@ export const WidgetEditModal = createModal>(({ dimensions={innerProps.previewDimensions} integrationData={innerProps.integrationData} onChangeOptions={handlePreviewOptionsChange} + PreviewWrapper={innerProps.previewWrapper} /> )} {innerProps.previewComponent && hasWidgetSettings && ( @@ -602,10 +623,6 @@ export const WidgetEditModal = createModal>(({ defaultTitle(t) { return t("item.edit.title"); }, - size: "100rem", - transitionProps: { - transition: "pop", - duration: 180, - }, + size: modalSizeForm, closeOnClickOutside: false, }); 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..55b76af7f --- /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:global(.iframe-wrapper) { + 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..9c965c78a --- /dev/null +++ b/packages/widgets/src/widget-card-shell.tsx @@ -0,0 +1,94 @@ +"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} + + ); +};