Compare commits

..
54 changed files with 628 additions and 1069 deletions

No files matched your search

@@ -194,17 +194,17 @@ To configure this, click on the small 3 dots at the top of the item in the edit
<figcaption>The tile menu contains the item editor and the separate move-and-resize tools.</figcaption> <figcaption>The tile menu contains the item editor and the separate move-and-resize tools.</figcaption>
</figure> </figure>
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:
<figure> <figure>
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} /> <img
<figcaption> src={editItemCurrent}
The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board. alt="App tile highlighted beside the Edit item side editor"
</figcaption> style={{ maxWidth: "620px" }}
/>
<figcaption>The selected tile stays highlighted while the rest of the board is subdued.</figcaption>
</figure> </figure>
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. 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. 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. Press and hold a handle, move the pointer, and release it when the tile has the size you want.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 144 KiB

@@ -16,7 +16,7 @@ import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import { import {
getBoardLaneColumnCount, getBoardLaneColumnCount,
getInitialBoardLogicalHeight, getInitialBoardLogicalHeight,
getLogicalGridSize, getLogicalTrackSize,
LOGICAL_GRID_GAP, LOGICAL_GRID_GAP,
getRootSectionForLane, getRootSectionForLane,
} from "~/components/board/layout"; } from "~/components/board/layout";
@@ -61,16 +61,16 @@ export const ClientBoard = () => {
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right"); const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount] const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
.filter((columnCount) => columnCount > 0) .filter((columnCount) => columnCount > 0)
.map(getLogicalGridSize); .map(getLogicalTrackSize);
const logicalWidth = const logicalWidth =
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP; laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId); const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null; const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING); const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
const gridTemplateColumns = [ const gridTemplateColumns = [
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null, leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
`${getLogicalGridSize(mainColumnCount)}px`, `${getLogicalTrackSize(mainColumnCount)}px`,
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null, rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
] ]
.filter((value) => value !== null) .filter((value) => value !== null)
.join(" "); .join(" ");
@@ -1,8 +1,6 @@
"use client"; "use client";
/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */ import { use, useCallback, useId, useState } from "react";
import { use, useCallback, useState } from "react";
import { ActionIcon, Affix, Card } from "@mantine/core"; import { ActionIcon, Affix, Card } from "@mantine/core";
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react"; import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { QueryErrorResetBoundary } from "@tanstack/react-query";
@@ -34,6 +32,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal); const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind)); const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
const previewId = useId();
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [isEditorLoading, setIsEditorLoading] = useState(false); const [isEditorLoading, setIsEditorLoading] = useState(false);
const [dimensions, setDimensions] = useState<Dimensions>({ const [dimensions, setDimensions] = useState<Dimensions>({
@@ -60,6 +59,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
preloadWidgetEditModal(); preloadWidgetEditModal();
try { try {
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : []; const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
const originalState = state;
openWidgetEditModal( openWidgetEditModal(
{ {
kind, kind,
@@ -68,12 +68,14 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
onSuccessfulEdit: (value) => { onSuccessfulEdit: (value) => {
setState(value); setState(value);
}, },
onPreviewChange: setState,
onPreviewRestore: () => setState(originalState),
integrationData: integrationData.filter((integration) => integrationData: integrationData.filter((integration) =>
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind), (currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
), ),
integrationSupport: hasIntegrationSupport, integrationSupport: hasIntegrationSupport,
settings, settings,
previewDimensions: dimensions, focusTargetId: previewId,
}, },
{ {
title(translate) { title(translate) {
@@ -89,10 +91,10 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
} finally { } finally {
setIsEditorLoading(false); setIsEditorLoading(false);
} }
}, [currentDefinition, dimensions, kind, openWidgetEditModal, settings, state, t, utils]); }, [currentDefinition, kind, openWidgetEditModal, previewId, settings, state, t, utils]);
const toggleEditMode = useCallback(() => { const toggleEditMode = useCallback(() => {
setEditMode((currentEditMode) => !currentEditMode); setEditMode((editMode) => !editMode);
showSuccessNotification({ showSuccessNotification({
message: editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled"), message: editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled"),
}); });
@@ -113,7 +115,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
return ( return (
<> <>
<Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}> <Card id={previewId} w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
<QueryErrorResetBoundary> <QueryErrorResetBoundary>
{({ reset }) => ( {({ reset }) => (
<ErrorBoundary <ErrorBoundary
@@ -1,14 +1,8 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { notFound, redirect } from "next/navigation"; import { notFound } from "next/navigation";
import { Center, Loader } from "@mantine/core"; 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 type { WidgetKind } from "@homarr/definitions";
import { ModalProvider } from "@homarr/modals";
import { widgetKinds } from "@homarr/widgets/manifest"; import { widgetKinds } from "@homarr/widgets/manifest";
import { WidgetPreviewPageContent } from "./_content"; import { WidgetPreviewPageContent } from "./_content";
@@ -22,24 +16,12 @@ export default async function WidgetPreview(props: Props) {
if (!widgetKinds.includes(kind as WidgetKind)) { if (!widgetKinds.includes(kind as WidgetKind)) {
notFound(); notFound();
} }
const session = await auth();
if (!session) {
redirect(`/auth/login?callbackUrl=${encodeURIComponent(`/widgets/${kind}`)}`);
}
const [integrations, board] = await Promise.all([getIntegrationsWithPermissionsAsync(session), getHomeBoardAsync()]);
return ( return (
<IntegrationProvider integrations={integrations}> <Center h="100vh">
<BoardPreviewProvider board={board}> <Suspense fallback={<Loader size="sm" />}>
<ModalProvider> <WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
<Center h="100vh"> </Suspense>
<Suspense fallback={<Loader size="sm" />}> </Center>
<WidgetPreviewPageContent key={kind} kind={kind as WidgetKind} />
</Suspense>
</Center>
</ModalProvider>
</BoardPreviewProvider>
</IntegrationProvider>
); );
} }
@@ -30,6 +30,13 @@ interface UpdateItemIntegrations {
newIntegrations: string[]; newIntegrations: string[];
} }
interface UpdateItemSettings {
itemId: string;
options: Record<string, unknown>;
advancedOptions: BoardItemAdvancedOptions;
integrationIds: string[];
}
export const useItemActions = () => { export const useItemActions = () => {
const { updateBoard } = useUpdateBoard(); const { updateBoard } = useUpdateBoard();
const currentLayoutId = useCurrentLayout(); const currentLayoutId = useCurrentLayout();
@@ -77,6 +84,20 @@ export const useItemActions = () => {
[updateBoard], [updateBoard],
); );
const updateItemSettings = useCallback(
({ itemId, options, advancedOptions, integrationIds }: UpdateItemSettings) => {
updateBoard((previous) => ({
...previous,
items: previous.items.map((item) => {
if (item.id !== itemId || !("integrationIds" in item)) return item;
return { ...item, options, advancedOptions, integrationIds };
}),
}));
},
[updateBoard],
);
const moveAndResizeItem = useCallback( const moveAndResizeItem = useCallback(
(input: Omit<MoveAndResizeItemInput, "layoutId">) => { (input: Omit<MoveAndResizeItemInput, "layoutId">) => {
updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId })); updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId }));
@@ -105,6 +126,7 @@ export const useItemActions = () => {
updateItemOptions, updateItemOptions,
updateItemAdvancedOptions, updateItemAdvancedOptions,
updateItemIntegrations, updateItemIntegrations,
updateItemSettings,
duplicateItem, duplicateItem,
createItem, createItem,
}; };
@@ -1,3 +1,16 @@
.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 { .settingsButton {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
@@ -1,8 +1,9 @@
import type { ComponentType, CSSProperties, MutableRefObject, PropsWithChildren } from "react"; import type { ComponentType, CSSProperties, MutableRefObject } from "react";
import { memo, Suspense, use, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, Suspense, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Box, Button, Center, Loader, Portal } from "@mantine/core"; import type { CardProps } from "@mantine/core";
import { Badge, Box, Button, Card, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks"; import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx"; import combineClasses from "clsx";
@@ -23,11 +24,9 @@ import {
} from "@homarr/widgets/definition"; } from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; 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 type { SectionItem } from "~/app/[locale]/boards/_types";
import { getLogicalTrackSize } from "~/components/board/layout"; 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 advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context"; import { useAdvancedFocus } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance"; import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
@@ -35,6 +34,7 @@ import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface"; import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
import { redirectShiftWheel } from "../advanced-focus/wheel"; import { redirectShiftWheel } from "../advanced-focus/wheel";
import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host"; import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css"; import itemContentClasses from "./item-content.module.css";
import { WidgetContextMenu } from "./widget-context-menu"; import { WidgetContextMenu } from "./widget-context-menu";
@@ -48,28 +48,47 @@ interface BoardItemContentProps {
item: SectionItem; item: SectionItem;
} }
type BoardItemCardProps = PropsWithChildren<{ 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 & {
item: SectionItem; item: SectionItem;
innerRef: (element: HTMLDivElement | null) => void; innerRef: (element: HTMLDivElement | null) => void;
}>; };
const BoardItemCard = ({ item, innerRef, children }: BoardItemCardProps) => { const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
return ( return (
<WidgetCardShell <Card
innerRef={innerRef} {...cardProps}
kind={item.kind} ref={innerRef}
advancedOptions={item.advancedOptions}
opacity={board.opacity / 100}
w="100%" w="100%"
h="100%" h="100%"
data-grid-item-content data-grid-item-content
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"board-grid-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius} radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0} p={0}
> >
{children} {children}
</WidgetCardShell> </Card>
); );
}; };
@@ -148,7 +167,6 @@ const LoadedBoardItemContent = ({
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
const previewEntranceFrameRef = useRef<number | null>(null); const previewEntranceFrameRef = useRef<number | null>(null);
const board = useRequiredBoard(); const board = useRequiredBoard();
const boardCanvasScale = useBoardCanvasScale();
const t = useI18n(); const t = useI18n();
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const settings = useSettings(); const settings = useSettings();
@@ -162,10 +180,6 @@ const LoadedBoardItemContent = ({
const { width: viewportWidth, height: viewportHeight } = viewportSize; const { width: viewportWidth, height: viewportHeight } = viewportSize;
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition); const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const widgetName = getWidgetName(item.kind, t); 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 advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
const advancedViewId = `advanced-focus-${item.id}`; const advancedViewId = `advanced-focus-${item.id}`;
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null; const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
@@ -277,24 +291,34 @@ const LoadedBoardItemContent = ({
: undefined; : undefined;
const widgetCard = ( const widgetCard = (
<WidgetCardShell <Card
innerRef={cardRef} ref={cardRef}
kind={item.kind}
advancedOptions={item.advancedOptions}
opacity={isAdvanced ? 0.98 : board.opacity / 100}
id={isPreview ? advancedViewId : undefined} id={isPreview ? advancedViewId : undefined}
role={isPreview ? "region" : undefined} role={isPreview ? "region" : undefined}
aria-label={isPreview ? advancedViewLabel : undefined} aria-label={isPreview ? advancedViewLabel : undefined}
data-advanced-focus-surface={isPreview || undefined} data-advanced-focus-surface={isPreview || undefined}
data-advanced-focus-item-id={isAdvanced ? item.id : undefined}
data-grid-item-content data-grid-item-content
radius={board.itemRadius} radius={board.itemRadius}
p={isAdvanced ? undefined : 0} p={isAdvanced ? undefined : 0}
h={isManual ? "100%" : isAdvanced ? undefined : "100%"} h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
w={isManual ? "100%" : isAdvanced ? undefined : "100%"} w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
className={combineClasses( className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"board-grid-item-content",
isPreview && advancedFocusClasses.surface, isPreview && advancedFocusClasses.surface,
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing, activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
item.advancedOptions.customCssClasses.join(" "),
)} )}
styles={{
root: {
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
containerType: "size",
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
style={previewStyle} style={previewStyle}
> >
<Box ref={contentRef} w="100%" h="100%" mih={0}> <Box ref={contentRef} w="100%" h="100%" mih={0}>
@@ -322,16 +346,15 @@ const LoadedBoardItemContent = ({
/> />
</ErrorBoundary> </ErrorBoundary>
</Box> </Box>
</WidgetCardShell> </Card>
); );
return ( return (
<> <>
{isEditMode && <BoardItemMenu item={menuItem} definition={definition} previewDimensions={previewDimensions} />} {isEditMode && <BoardItemMenu item={menuItem} definition={definition} />}
<WidgetContextMenu <WidgetContextMenu
item={item} item={item}
definition={definition} definition={definition}
previewDimensions={previewDimensions}
widgetStateRef={widgetStateRef} widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef} widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef} sourceRef={sourceRef}
@@ -396,12 +419,25 @@ const LoadedBoardItemContent = ({
)} )}
</Box> </Box>
</WidgetContextMenu> </WidgetContextMenu>
{!isAdvanced && ( {!isAdvanced && item.advancedOptions.title?.trim() && (
<WidgetTitleBadge <Badge
advancedOptions={item.advancedOptions} pos="absolute"
opacity={board.opacity / 100} style={{ zIndex: "var(--mantine-z-index-app)" }}
top={2}
left={16}
size="xs"
radius={board.itemRadius} radius={board.itemRadius}
/> styles={{
root: {
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
"--opacity": board.opacity / 100,
},
}}
className={itemContentClasses.badge}
c="var(--mantine-color-text)"
>
{item.advancedOptions.title}
</Badge>
)} )}
</> </>
); );
@@ -3,6 +3,7 @@ import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react"; import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { getWidgetName } from "@homarr/definitions"; import { getWidgetName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
@@ -21,7 +22,6 @@ import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-
interface BoardItemMenuProps { interface BoardItemMenuProps {
item: SectionItem; item: SectionItem;
definition: WidgetDefinition; definition: WidgetDefinition;
previewDimensions: { width: number; height: number; scale?: number };
resetErrorBoundary?: () => void; resetErrorBoundary?: () => void;
} }
@@ -32,8 +32,9 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
return <BoardItemMenuInner {...props} />; return <BoardItemMenuInner {...props} />;
}; };
const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBoundary }: BoardItemMenuProps) => { const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
const board = useRequiredBoard();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const refResetErrorBoundaryOnNextRender = useRef(false); const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useI18n("item"); const tItem = useI18n("item");
@@ -41,8 +42,7 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
const { openModal } = useModalAction(LazyWidgetEditModal); const { openModal } = useModalAction(LazyWidgetEditModal);
const openMoveModal = useOpenItemMoveModal(); const openMoveModal = useOpenItemMoveModal();
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = const { updateItemSettings, duplicateItem, removeItem } = useItemActions();
useItemActions();
const { integrations: integrationData, section } = useSectionContext(); const { integrations: integrationData, section } = useSectionContext();
const settings = useSettings(); const settings = useSettings();
const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t); const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
@@ -56,6 +56,11 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
}, [item, resetErrorBoundary]); }, [item, resetErrorBoundary]);
const openEditModal = () => { const openEditModal = () => {
const originalSettings = {
options: item.options,
advancedOptions: item.advancedOptions,
integrationIds: item.integrationIds,
};
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
@@ -65,19 +70,14 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
options: item.options, options: item.options,
integrationIds: item.integrationIds, integrationIds: item.integrationIds,
}, },
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => { onPreviewChange: (value) => {
updateItemOptions({ updateItemSettings({ itemId: item.id, ...value });
itemId: item.id, },
newOptions: options, onPreviewRestore: () => {
}); updateItemSettings({ itemId: item.id, ...originalSettings });
updateItemAdvancedOptions({ },
itemId: item.id, onSuccessfulEdit: (value) => {
newAdvancedOptions: advancedOptions, updateItemSettings({ itemId: item.id, ...value });
});
updateItemIntegrations({
itemId: item.id,
newIntegrations: integrationIds,
});
refResetErrorBoundaryOnNextRender.current = true; refResetErrorBoundaryOnNextRender.current = true;
}, },
onIntegrationSaved: resetErrorBoundary, onIntegrationSaved: resetErrorBoundary,
@@ -90,7 +90,7 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
integrationSupport: "supportedIntegrations" in definition, integrationSupport: "supportedIntegrations" in definition,
settings, settings,
itemId: item.id, itemId: item.id,
previewDimensions, boardId: board.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
{ {
@@ -30,7 +30,6 @@ import {
getIntegrationName, getIntegrationName,
getWidgetName, getWidgetName,
getRootSectionLane, getRootSectionLane,
widgetDefaultSizes,
widgetIntegrationSupport, widgetIntegrationSupport,
widgetKinds, widgetKinds,
widgetKindsWithOptionalIntegrations, widgetKindsWithOptionalIntegrations,
@@ -48,7 +47,6 @@ import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr
import type { EmptySection } from "~/app/[locale]/boards/_types"; import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useSetupAnalytics } from "~/components/create/setup-analytics"; import { useSetupAnalytics } from "~/components/create/setup-analytics";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { getSectionGridColumnCount } from "../sections/grid/section-grid-placements";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import type { WidgetConnectionStatus } from "./item-select-data"; import type { WidgetConnectionStatus } from "./item-select-data";
import { import {
@@ -78,8 +76,6 @@ export interface WidgetItemData {
} }
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 }; const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
const defaultWidgetSize = { width: 1, height: 1 };
const maximumWidgetHeight = 64;
const ItemSelectModalContent = ({ const ItemSelectModalContent = ({
actions, actions,
@@ -128,13 +124,6 @@ const ItemSelectModalContent = ({
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId) const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
? targetSectionId ? targetSectionId
: (placementOptions[0]?.value ?? null); : (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<string | null>(null); const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const selectionLock = useRef(false); const selectionLock = useRef(false);
@@ -257,7 +246,6 @@ const ItemSelectModalContent = ({
try { try {
const definition = await loadWidgetDefinition("customApi"); const definition = await loadWidgetDefinition("customApi");
const itemId = createId(); const itemId = createId();
const initialSize = widgetDefaultSizes.customApi ?? defaultWidgetSize;
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
const options = { const options = {
...defaultOptions, ...defaultOptions,
@@ -275,7 +263,7 @@ const ItemSelectModalContent = ({
options, options,
integrationIds: [], integrationIds: [],
}, },
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }, selectedSize) => { onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
const updatedBoard = createItem({ const updatedBoard = createItem({
id: itemId, id: itemId,
kind: "customApi", kind: "customApi",
@@ -283,14 +271,13 @@ const ItemSelectModalContent = ({
options: configuredOptions, options: configuredOptions,
targetSectionId: effectiveSectionId ?? undefined, targetSectionId: effectiveSectionId ?? undefined,
advancedOptions, advancedOptions,
size: selectedSize ?? initialSize,
}); });
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name); notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
}, },
integrationData: [], integrationData: [],
integrationSupport: false, integrationSupport: false,
settings, settings,
previewResize: { initialSize, maximumSize: maximumWidgetSize }, boardId: board.id,
}, },
{ {
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`, title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
@@ -325,7 +312,6 @@ const ItemSelectModalContent = ({
const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations); const matchingIntegrationCount = Math.min(matchingIntegrations.length, maxIntegrations);
const itemId = createId(); const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
const initialSize = widgetDefaultSizes[kind] ?? defaultWidgetSize;
const openEditor = ( const openEditor = (
availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[], availableIntegrations: { id: string; name: string; url: string; kind: IntegrationKind }[],
) => { ) => {
@@ -341,7 +327,7 @@ const ItemSelectModalContent = ({
options: defaultOptions, options: defaultOptions,
integrationIds: selectedIntegrationIds, integrationIds: selectedIntegrationIds,
}, },
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }, selectedSize) => { onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
const updatedBoard = createItem({ const updatedBoard = createItem({
id: itemId, id: itemId,
kind, kind,
@@ -349,7 +335,6 @@ const ItemSelectModalContent = ({
integrationIds: newIntegrationIds, integrationIds: newIntegrationIds,
targetSectionId: effectiveSectionId ?? undefined, targetSectionId: effectiveSectionId ?? undefined,
advancedOptions, advancedOptions,
size: selectedSize ?? initialSize,
}); });
if (!notifyCreated(updatedBoard, itemId, getWidgetName(kind, t))) return; if (!notifyCreated(updatedBoard, itemId, getWidgetName(kind, t))) return;
trackSetup("widget-completed", { trackSetup("widget-completed", {
@@ -362,7 +347,7 @@ const ItemSelectModalContent = ({
integrationData: availableIntegrations, integrationData: availableIntegrations,
integrationSupport: hasIntegrationSupport, integrationSupport: hasIntegrationSupport,
settings, settings,
previewResize: { initialSize, maximumSize: maximumWidgetSize }, boardId: board.id,
}, },
{ {
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`, title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
@@ -1,6 +1,5 @@
"use client"; "use client";
import type { PropsWithChildren } from "react";
import { Suspense, use, useCallback, useMemo, useState } from "react"; import { Suspense, use, useCallback, useMemo, useState } from "react";
import { Center, Loader } from "@mantine/core"; import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
@@ -8,49 +7,16 @@ import type { FallbackProps } from "react-error-boundary";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { widgetDefaultSizes } from "@homarr/definitions"; import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import { createModal, useModalAction } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import type * as WidgetModalsModule from "@homarr/widgets/modals"; import type * as WidgetModalsModule from "@homarr/widgets/modals";
import type { WidgetEditModalProps, WidgetEditModalSize } 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 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 { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form"; import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
import type { EmbeddedIntegrationEditFormProps } 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 <AssistantContext.Provider value={previewAssistant}>{children}</AssistantContext.Provider>;
};
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined; let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
export const loadWidgetEditModal = () => { export const loadWidgetEditModal = () => {
@@ -117,7 +83,6 @@ interface LazyWidgetEditModalContentProps {
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal()); const { WidgetEditModal } = use(loadWidgetEditModal());
const { default: PreviewComponent } = use(loadWidgetComponent(props.innerProps.kind));
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal); const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const Component = WidgetEditModal.component; const Component = WidgetEditModal.component;
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
@@ -149,18 +114,6 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id)); const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
return [...existing, ...additional]; return [...existing, ...additional];
}, [props.innerProps.integrationData, createdIntegrations]); }, [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 ( return (
<Component <Component
@@ -169,10 +122,6 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
...props.innerProps, ...props.innerProps,
integrationData: combinedIntegrationData, integrationData: combinedIntegrationData,
integrationEditForm: LazyIntegrationEditForm, integrationEditForm: LazyIntegrationEditForm,
previewComponent: PreviewComponent,
previewDimensions,
previewResize,
previewWrapper: PreviewRuntimeWrapper,
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined, onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
}} }}
/> />
@@ -208,10 +157,8 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
defaultTitle(t) { defaultTitle(t) {
return t("item.edit.title"); return t("item.edit.title");
}, },
size: "100rem", presentation: "inspector",
transitionProps: { withOverlay: false,
transition: "pop", size: modalSizeForm,
duration: 180,
},
closeOnClickOutside: false, closeOnClickOutside: false,
}); });
@@ -35,7 +35,6 @@ import { matchesWidgetItemQuery } from "./widget-query-scope";
interface WidgetContextMenuProps { interface WidgetContextMenuProps {
item: SectionItem; item: SectionItem;
definition: WidgetDefinition; definition: WidgetDefinition;
previewDimensions: { width: number; height: number; scale?: number };
widgetStateRef: MutableRefObject<Record<string, unknown> | null>; widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef; widgetRuntimeRef: WidgetRuntimeRef;
sourceRef: RefObject<HTMLElement | null>; sourceRef: RefObject<HTMLElement | null>;
@@ -45,7 +44,6 @@ interface WidgetContextMenuProps {
export const WidgetContextMenu = ({ export const WidgetContextMenu = ({
item, item,
definition, definition,
previewDimensions,
widgetRuntimeRef, widgetRuntimeRef,
sourceRef, sourceRef,
children, children,
@@ -60,7 +58,7 @@ export const WidgetContextMenu = ({
const tCommon = useI18n("common.action"); const tCommon = useI18n("common.action");
const settings = useSettings(); const settings = useSettings();
const { openModal } = useModalAction(LazyWidgetEditModal); const { openModal } = useModalAction(LazyWidgetEditModal);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); const { updateItemSettings } = useItemActions();
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0; const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, { const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: hasSupportedIntegrations, enabled: hasSupportedIntegrations,
@@ -156,6 +154,11 @@ export const WidgetContextMenu = ({
}) ?? []; }) ?? [];
const openEditModal = useCallback(() => { const openEditModal = useCallback(() => {
const originalSettings = {
options: item.options,
advancedOptions: item.advancedOptions,
integrationIds: item.integrationIds,
};
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
@@ -165,10 +168,13 @@ export const WidgetContextMenu = ({
options, options,
integrationIds: item.integrationIds, integrationIds: item.integrationIds,
}, },
onPreviewChange: (value) => {
updateItemSettings({ itemId: item.id, ...value });
},
onPreviewRestore: () => {
updateItemSettings({ itemId: item.id, ...originalSettings });
},
onSuccessfulEdit: (editResult) => { onSuccessfulEdit: (editResult) => {
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
persistBoard((previous) => ({ persistBoard((previous) => ({
...previous, ...previous,
items: previous.items.map((boardItem) => items: previous.items.map((boardItem) =>
@@ -193,7 +199,6 @@ export const WidgetContextMenu = ({
settings, settings,
itemId: item.id, itemId: item.id,
boardId: board.id, boardId: board.id,
previewDimensions,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -210,11 +215,8 @@ export const WidgetContextMenu = ({
openModal, openModal,
options, options,
persistBoard, persistBoard,
previewDimensions,
settings, settings,
updateItemAdvancedOptions, updateItemSettings,
updateItemIntegrations,
updateItemOptions,
]); ]);
const handleToggle = useCallback( const handleToggle = useCallback(
@@ -2,16 +2,17 @@
The board uses fixed logical units: The board uses fixed logical units:
- one grid track has a `212 × 212` logical-pixel footprint; - one `1 × 1` item is exactly `200 × 200` logical pixels;
- cards are inset by `10` visual pixels on every side, matching the legacy GridStack gutter; - the gap matches Mantine's `sm` spacing at `12` logical pixels, giving a `212` pixel track pitch;
- multi-cell items occupy complete track footprints (`2 × 1` is `424 × 212` before the card inset); - multi-cell items include the gaps between their cells (`2 × 1` is `412 × 200`);
- the canvas is zoomed once with one uniform scale derived from its available width. - the canvas is zoomed once with one uniform scale derived from its available width.
Grid footprints keep the same logical dimensions at every viewport. Widget cards Items keep the same logical dimensions at every viewport. The viewport reserves
use a scale-compensated inset so their painted size and content measurements the painted size while the inner canvas remains at its logical dimensions, so a
match the legacy responsive grid. The visual canvas always fits the available widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
width, every root lane keeps at least one viewport of height, and edit-mode stable. The visual canvas always fits the available width, every root lane
previews extend it downward as needed. keeps at least one viewport of height, and edit-mode previews extend it downward
as needed.
The read-only renderer should: The read-only renderer should:
@@ -2,7 +2,7 @@ import type { BoardLane } from "@homarr/definitions";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions"; import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import type { Board, EmptySection } from "~/app/[locale]/boards/_types"; import type { Board, EmptySection } from "~/app/[locale]/boards/_types";
import { getLogicalGridSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry"; import { getLogicalTrackSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry";
import { getCollapsedDisplayLayout } from "./reflow"; import { getCollapsedDisplayLayout } from "./reflow";
import type { GridPlacement } from "./types"; import type { GridPlacement } from "./types";
@@ -90,5 +90,5 @@ export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) =>
return [getLayoutRowCount(displayPlacements)]; return [getLayoutRowCount(displayPlacements)];
}); });
return getLogicalGridSize(Math.max(0, ...rowCounts)); return getLogicalTrackSize(Math.max(0, ...rowCounts));
}; };
@@ -10,23 +10,12 @@ export const getLogicalTrackSize = (trackCount: number) => {
return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP; 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 => ({ export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({
position: "absolute", position: "absolute",
left: x * LOGICAL_GRID_PITCH, left: x * LOGICAL_GRID_PITCH,
top: y * LOGICAL_GRID_PITCH, top: y * LOGICAL_GRID_PITCH,
width: getLogicalGridSize(w), width: getLogicalTrackSize(w),
height: getLogicalGridSize(h), height: getLogicalTrackSize(h),
}); });
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) => export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
@@ -36,7 +25,7 @@ export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale
if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1; if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1;
if (!Number.isFinite(visualScale) || visualScale <= 0) return 1; if (!Number.isFinite(visualScale) || visualScale <= 0) return 1;
return Math.max(1, Math.ceil(visualHeight / visualScale / LOGICAL_GRID_PITCH)); return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH));
}; };
export const normalizeGridPlacement = <TPlacement extends GridPlacement>( export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
@@ -8,7 +8,7 @@ import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/ite
import { import {
getBoardLaneColumnCount, getBoardLaneColumnCount,
getInitialBoardLogicalHeight, getInitialBoardLogicalHeight,
getLogicalGridSize, getLogicalTrackSize,
getRootSectionForLane, getRootSectionForLane,
} from "../index"; } from "../index";
@@ -131,6 +131,6 @@ describe("board lanes", () => {
}, },
]; ];
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalGridSize(4)); expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalTrackSize(4));
}); });
}); });
@@ -3,28 +3,24 @@ import { describe, expect, test } from "vitest";
import { import {
getGridRowCountForVisualHeight, getGridRowCountForVisualHeight,
getLayoutRowCount, getLayoutRowCount,
getLogicalGridSize,
getLogicalItemStyle, getLogicalItemStyle,
getLogicalTrackSize, getLogicalTrackSize,
normalizeGridPlacement, normalizeGridPlacement,
} from "../index"; } from "../index";
describe("fixed dashboard geometry", () => { describe("fixed dashboard geometry", () => {
test("separates content track sizes from complete grid footprints", () => { test("keeps a 1x1 item exactly 200px with a fixed 12px gap", () => {
expect(getLogicalTrackSize(1)).toBe(200); expect(getLogicalTrackSize(1)).toBe(200);
expect(getLogicalTrackSize(0.5)).toBe(94); expect(getLogicalTrackSize(0.5)).toBe(94);
expect(getLogicalTrackSize(2)).toBe(412); expect(getLogicalTrackSize(2)).toBe(412);
expect(getLogicalTrackSize(3)).toBe(624); 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({ expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
position: "absolute", position: "absolute",
left: 212, left: 212,
top: 424, top: 424,
width: 424, width: 412,
height: 636, height: 624,
}); });
}); });
@@ -1,4 +1,4 @@
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout"; import type { GridPlacement } from "~/components/board/layout";
import type { GridResizeDirection } from "./types"; import type { GridResizeDirection } from "./types";
@@ -38,7 +38,7 @@ export const getSnappedGridCoordinates = ({
target: RectangleLike; target: RectangleLike;
columnCount: number; columnCount: number;
}) => { }) => {
const logicalWidth = getLogicalGridSize(columnCount); const logicalWidth = getLogicalTrackSize(columnCount);
const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0; const visualScale = logicalWidth > 0 ? target.width / logicalWidth : 0;
if (!Number.isFinite(visualScale) || visualScale <= 0) return null; if (!Number.isFinite(visualScale) || visualScale <= 0) return null;
@@ -39,7 +39,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { getWidgetName } from "@homarr/definitions"; import { getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { import {
beginGridTransaction, beginGridTransaction,
cancelGridTransaction, cancelGridTransaction,
@@ -792,8 +792,8 @@ export default function GridEditor({
data-grid-depth={depth} data-grid-depth={depth}
style={ style={
{ {
width: getLogicalGridSize(columnCount), width: getLogicalTrackSize(columnCount),
height: getLogicalGridSize(rowCount), height: getLogicalTrackSize(rowCount),
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`, "--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
} as CSSProperties } as CSSProperties
} }
@@ -1234,8 +1234,8 @@ const getEntryVisualScale = (element: HTMLElement | null, placement: SectionGrid
if (!element) return null; if (!element) return null;
const rectangle = element.getBoundingClientRect(); const rectangle = element.getBoundingClientRect();
const scale = { const scale = {
x: rectangle.width / getLogicalGridSize(placement.w), x: rectangle.width / getLogicalTrackSize(placement.w),
y: rectangle.height / getLogicalGridSize(placement.h), y: rectangle.height / getLogicalTrackSize(placement.h),
}; };
return Number.isFinite(scale.x) && scale.x > 0 && Number.isFinite(scale.y) && scale.y > 0 ? scale : null; return Number.isFinite(scale.x) && scale.x > 0 && Number.isFinite(scale.y) && scale.y > 0 ? scale : null;
}; };
@@ -1,4 +1,4 @@
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout"; import type { GridPlacement } from "~/components/board/layout";
export interface GridPreviewDomState { export interface GridPreviewDomState {
@@ -75,8 +75,8 @@ export const clearGridPreviewGeometry = (
const applyPreviewGeometry = (element: HTMLElement, controlled: GridPlacement, preview: GridPlacement) => { 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-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-y", `${(preview.y - controlled.y) * LOGICAL_GRID_PITCH}px`);
element.style.setProperty("--board-grid-preview-width", `${getLogicalGridSize(preview.w)}px`); element.style.setProperty("--board-grid-preview-width", `${getLogicalTrackSize(preview.w)}px`);
element.style.setProperty("--board-grid-preview-height", `${getLogicalGridSize(preview.h)}px`); element.style.setProperty("--board-grid-preview-height", `${getLogicalTrackSize(preview.h)}px`);
element.setAttribute("data-grid-preview", "true"); element.setAttribute("data-grid-preview", "true");
setPlacementAttributes(element, preview); setPlacementAttributes(element, preview);
}; };
@@ -3,7 +3,7 @@
import type { RefObject } from "react"; import type { RefObject } from "react";
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react"; import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
import { getLayoutRowCount, getLogicalItemStyle, getLogicalGridSize } from "~/components/board/layout"; import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout";
import type { TransactionalGridState } from "./dnd"; import type { TransactionalGridState } from "./dnd";
import type { GridInteractionStore } from "./grid-interaction-store"; import type { GridInteractionStore } from "./grid-interaction-store";
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry"; import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
@@ -87,7 +87,7 @@ export const GridPreviewLayer = ({
} else { } else {
delete grid.dataset.dndPreviewRevision; delete grid.dataset.dndPreviewRevision;
} }
grid.style.height = `${getLogicalGridSize(renderedRowCount)}px`; grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`;
const viewport = grid.parentElement; const viewport = grid.parentElement;
if (viewport?.hasAttribute("data-section-id")) { 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 // 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. // drag ended, since nothing else would ever clear it back out.
if (maxRowCount === null) { if (maxRowCount === null) {
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalGridSize(renderedRowCount)}px`); viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
} else { } else {
viewport.style.removeProperty("--board-grid-drag-height"); viewport.style.removeProperty("--board-grid-drag-height");
} }
@@ -1,4 +1,4 @@
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout"; import type { GridPlacement } from "~/components/board/layout";
interface ResizeOutlinePreview { interface ResizeOutlinePreview {
@@ -31,8 +31,8 @@ export const createGridResizeOutlineController = (
outline.style.position = "absolute"; outline.style.position = "absolute";
outline.style.left = `${(preview.placement.x - visualX) * LOGICAL_GRID_PITCH}px`; outline.style.left = `${(preview.placement.x - visualX) * LOGICAL_GRID_PITCH}px`;
outline.style.top = `${(preview.placement.y - visualY) * LOGICAL_GRID_PITCH}px`; outline.style.top = `${(preview.placement.y - visualY) * LOGICAL_GRID_PITCH}px`;
outline.style.width = `${getLogicalGridSize(preview.placement.w)}px`; outline.style.width = `${getLogicalTrackSize(preview.placement.w)}px`;
outline.style.height = `${getLogicalGridSize(preview.placement.h)}px`; outline.style.height = `${getLogicalTrackSize(preview.placement.h)}px`;
outline.dataset.gridResizeValid = String(preview.valid); outline.dataset.gridResizeValid = String(preview.valid);
}; };
@@ -50,10 +50,6 @@
overflow: visible; overflow: visible;
} }
.staticItem[data-type="item"] > .contentMount {
inset: calc(10px * var(--board-canvas-inverse-scale, 1));
}
.staticItem:has([data-board-container-label]) { .staticItem:has([data-board-container-label]) {
z-index: 2; z-index: 2;
} }
@@ -14,7 +14,7 @@ import {
getEditableCanvasAttributes, getEditableCanvasAttributes,
getGridRowCountForVisualHeight, getGridRowCountForVisualHeight,
getLayoutRowCount, getLayoutRowCount,
getLogicalGridSize, getLogicalTrackSize,
getReadonlyCanvasAttributes, getReadonlyCanvasAttributes,
normalizeGridPlacement, normalizeGridPlacement,
} from "~/components/board/layout"; } 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. // of expanding to fit every widget, so its viewport height is capped independently of rowCount.
const isScrollableContainer = section.kind === "container" && section.options.scrollable; const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount; const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
const logicalWidth = getLogicalGridSize(columnCount); const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalGridSize(rowCount); const logicalHeight = getLogicalTrackSize(rowCount);
const viewportHeight = getLogicalGridSize(viewportRowCount); const viewportHeight = getLogicalTrackSize(viewportRowCount);
// A collapsed container's compact coordinates are display-only. Its own // A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it. // nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id); const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client"; import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getCollapsedDisplayLayout, getLogicalGridSize } from "../../../layout"; import { getCollapsedDisplayLayout, LOGICAL_GRID_CELL_SIZE } from "../../../layout";
import { FixedGridItem } from "../fixed-grid-item"; import { FixedGridItem } from "../fixed-grid-item";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
@@ -250,14 +250,14 @@ describe("fixed grid item behavior", () => {
expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).toBeNull(); expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).toBeNull();
}); });
test("renders a view-only 1x1 card at the complete grid footprint", () => { test("renders a view-only 1x1 card at the fixed 200px logical size", () => {
mocks.editMode = false; mocks.editMode = false;
renderWeather(root); renderWeather(root);
const item = container.querySelector<HTMLElement>('[data-grid-id="weather"]'); const item = container.querySelector<HTMLElement>('[data-grid-id="weather"]');
expect(item).not.toBeNull(); expect(item).not.toBeNull();
expect(item?.style.width).toBe(`${getLogicalGridSize(1)}px`); expect(item?.style.width).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
expect(item?.style.height).toBe(`${getLogicalGridSize(1)}px`); expect(item?.style.height).toBe(`${LOGICAL_GRID_CELL_SIZE}px`);
expect(item?.getAttribute("role")).toBeNull(); expect(item?.getAttribute("role")).toBeNull();
act(() => item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))); act(() => item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })));
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test } from "vitest"; import { beforeEach, describe, expect, test } from "vitest";
import { getLogicalGridSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry"; 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?.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-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-y")).toBe(`${LOGICAL_GRID_PITCH}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalGridSize(3)}px`); expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalTrackSize(3)}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalGridSize(4)}px`); expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalTrackSize(4)}px`);
expect(active?.dataset.gridW).toBe("3"); expect(active?.dataset.gridW).toBe("3");
expect(active?.dataset.gridH).toBe("4"); expect(active?.dataset.gridH).toBe("4");
}); });
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { getLogicalGridSize } from "~/components/board/layout"; import { getLogicalTrackSize } from "~/components/board/layout";
import { createGridResizeOutlineController } from "../grid-resize-outline"; import { createGridResizeOutlineController } from "../grid-resize-outline";
@@ -30,8 +30,8 @@ describe("grid resize outline", () => {
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]"); const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
expect(outline?.style.left).toBe("0px"); expect(outline?.style.left).toBe("0px");
expect(outline?.style.top).toBe("0px"); expect(outline?.style.top).toBe("0px");
expect(outline?.style.width).toBe(`${getLogicalGridSize(1)}px`); expect(outline?.style.width).toBe(`${getLogicalTrackSize(1)}px`);
expect(outline?.style.height).toBe(`${getLogicalGridSize(2)}px`); expect(outline?.style.height).toBe(`${getLogicalTrackSize(2)}px`);
expect(outline?.dataset.gridResizeValid).toBe("false"); expect(outline?.dataset.gridResizeValid).toBe("false");
controller.schedule(null); controller.schedule(null);
Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

+3 -24
View File
@@ -10,16 +10,12 @@ import { clientApi } from "@homarr/api/client";
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "./layout-selection"; import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "./layout-selection";
import { updateBoardName } from "./updater"; import { updateBoardName } from "./updater";
type Board = RouterOutputs["board"]["getBoardByName"]; const BoardContext = createContext<{
board: RouterOutputs["board"]["getBoardByName"];
interface BoardContextValue {
board: Board;
layoutOverrideId: string | null; layoutOverrideId: string | null;
currentLayout: string; currentLayout: string;
initialViewportWidth: number; initialViewportWidth: number;
} } | null>(null);
const BoardContext = createContext<BoardContextValue | null>(null);
const subscribeToViewport = (onStoreChange: () => void) => { const subscribeToViewport = (onStoreChange: () => void) => {
if (typeof window === "undefined") return () => undefined; if (typeof window === "undefined") return () => undefined;
@@ -41,23 +37,6 @@ const subscribeToViewport = (onStoreChange: () => void) => {
}; };
}; };
export const BoardPreviewProvider = ({ children, board }: PropsWithChildren<{ board: Board }>) => {
const currentLayout = board.layouts[0]?.id ?? "";
return (
<BoardContext.Provider
value={{
board,
layoutOverrideId: null,
currentLayout,
initialViewportWidth: 0,
}}
>
{children}
</BoardContext.Provider>
);
};
export const BoardProvider = ({ export const BoardProvider = ({
children, children,
initialBoard, initialBoard,
@@ -7,9 +7,11 @@
.inspectorContent { .inspectorContent {
--modal-content-flex: 0 0 100%; --modal-content-flex: 0 0 100%;
--modal-content-max-height: min(88dvh, 52rem); --modal-content-max-height: min(88dvh, 52rem);
--modal-size: 100%;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0;
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 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-flex: 0 0 min(34rem, 46vw);
--modal-content-height: 100dvh; --modal-content-height: 100dvh;
--modal-content-max-height: 100dvh; --modal-content-max-height: 100dvh;
--modal-size: min(34rem, 46vw);
width: min(34rem, 46vw); width: min(34rem, 46vw);
border-radius: 0; border-radius: 0;
} }
.inspectorInner:has([data-widget-inspector-side="left"]) {
justify-content: flex-start;
}
} }
+1
View File
@@ -19,6 +19,7 @@ export type CreateModalOptions = Pick<
| "centered" | "centered"
| "keepMounted" | "keepMounted"
| "withCloseButton" | "withCloseButton"
| "withOverlay"
| "zIndex" | "zIndex"
| "scrollAreaComponent" | "scrollAreaComponent"
| "yOffset" | "yOffset"
+2 -11
View File
@@ -2083,12 +2083,10 @@
"label": "Section or rail" "label": "Section or rail"
}, },
"width": { "width": {
"label": "Width", "label": "Width"
"shortLabel": "W"
}, },
"height": { "height": {
"label": "Height", "label": "Height"
"shortLabel": "H"
}, },
"xOffset": { "xOffset": {
"label": "X offset" "label": "X offset"
@@ -2117,13 +2115,6 @@
"loading": "Loading integration", "loading": "Loading integration",
"loadError": "This integration could not be loaded. Try again before changing its connection settings." "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": { "advancedOptions": {
"label": "Advanced options", "label": "Advanced options",
"title": "Advanced item options" "title": "Advanced item options"
+1 -2
View File
@@ -30,8 +30,7 @@
"./widget-integration-select": "./src/widget-integration-select.tsx", "./widget-integration-select": "./src/widget-integration-select.tsx",
"./modals": "./src/modals/index.ts", "./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.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": { "scripts": {
"clean": "rm -rf .turbo node_modules", "clean": "rm -rf .turbo node_modules",
@@ -17,7 +17,10 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
const t = useI18n("item.edit"); const t = useI18n("item.edit");
const theme = useMantineTheme(); const theme = useMantineTheme();
const form = useForm({ const form = useForm({
initialValues: innerProps.advancedOptions, initialValues: {
...innerProps.advancedOptions,
title: innerProps.advancedOptions.title ?? "",
},
}); });
const handleSubmit = (values: BoardItemAdvancedOptions) => { const handleSubmit = (values: BoardItemAdvancedOptions) => {
innerProps.onSuccess({ innerProps.onSuccess({
@@ -0,0 +1,118 @@
"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, focusTargetId: string | undefined) => {
const [focusRect, setFocusRect] = useState<FocusRect | null>(null);
useIsomorphicEffect(() => {
let element: HTMLElement | null = null;
if (focusTargetId) {
element = document.getElementById(focusTargetId);
} else if (itemId) {
element = document.querySelector<HTMLElement>(`[data-advanced-focus-item-id="${CSS.escape(itemId)}"]`);
element ??= document.querySelector<HTMLElement>(`[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);
};
}, [focusTargetId, 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 }) => (
<Portal>
{focusRect ? (
<>
<Overlay fixed backgroundOpacity={0} zIndex={getDefaultZIndex("modal")} />
<Box
aria-hidden
className={classes.focusFrame}
style={{
top: focusRect.top,
left: focusRect.left,
width: focusRect.width,
height: focusRect.height,
zIndex: getDefaultZIndex("modal"),
}}
/>
</>
) : (
<Overlay fixed color="#000" backgroundOpacity={0.58} blur={1} zIndex={getDefaultZIndex("modal")} />
)}
</Portal>
);
@@ -1,109 +1,23 @@
.workspace { .panel {
min-width: 0;
}
.settings {
min-width: 0;
container-type: inline-size;
}
.switchGrid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: minmax(0, 1fr);
gap: var(--mantine-spacing-lg); align-items: stretch;
align-items: start; gap: var(--mantine-spacing-sm);
}
.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 { .switchOption {
display: flex; display: flex;
cursor: pointer; height: 100%;
height: 4.5rem; min-height: 4.25rem;
align-items: center; align-items: center;
padding: 0.625rem 0.75rem; padding: 0.625rem 0.75rem;
border: 1px solid var(--mantine-color-default-border); border: 1px solid var(--mantine-color-default-border);
@@ -121,7 +35,7 @@
flex-direction: row-reverse; flex-direction: row-reverse;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--mantine-spacing-md); gap: var(--mantine-spacing-sm);
} }
.switchOption :global(.mantine-Switch-labelWrapper) { .switchOption :global(.mantine-Switch-labelWrapper) {
@@ -129,63 +43,48 @@
flex: 1; flex: 1;
} }
.switchOption :global(.mantine-Switch-description) {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.footer { .footer {
position: sticky; position: sticky;
z-index: 3; z-index: 2;
bottom: calc(var(--mantine-spacing-md) * -1); bottom: 0;
margin-inline: calc(var(--mantine-spacing-md) * -1); margin-inline: calc(var(--mantine-spacing-md) * -1);
margin-bottom: calc(var(--mantine-spacing-md) * -1); margin-bottom: calc(var(--mantine-spacing-md) * -1);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border); border-top: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent); background: color-mix(in srgb, var(--mantine-color-body) 96%, transparent);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
@keyframes dock-widget { .footerActions {
from { margin-inline-start: auto;
opacity: 0; }
transform: translate(-1.25rem, 1rem) scale(0.92);
}
to { @container (min-width: 30rem) {
opacity: 1; .switchGrid {
transform: none; grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
} }
} }
@media (max-width: 68em) { .focusFrame {
.workspace[data-with-preview][data-with-settings] { position: fixed;
grid-template-columns: 1fr; pointer-events: none;
} border: 2px solid var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
.previewPanel { box-shadow:
position: static; 0 0 0 2px color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent),
max-width: 44rem; 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);
.previewDivider { transition:
display: none; top 160ms ease,
} left 160ms ease,
width 160ms ease,
.previewCanvas { height 160ms ease;
height: 24rem;
}
.settingsPanel {
max-height: none;
overflow: visible;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.previewViewport { .focusFrame {
animation: none; transition: none;
} }
} }
+231 -511
View File
@@ -1,38 +1,18 @@
"use client"; "use client";
/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */
import { useEffect, useId, useRef, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import type { ComponentType, FormEvent, PropsWithChildren } from "react"; import type { ComponentType, FormEvent } from "react";
import { import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
Alert,
Badge,
Box,
Button,
Center,
Divider,
Group,
NumberInput,
SimpleGrid,
Stack,
Tabs,
Text,
Tooltip,
} from "@mantine/core";
import { schemaResolver } from "@mantine/form"; import { schemaResolver } from "@mantine/form";
import { useElementSize } from "@mantine/hooks"; import { IconArrowLeft, IconPencil } from "@tabler/icons-react";
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 { z } from "zod/v4";
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
import { IntegrationProvider, useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context"; import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals"; import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator"; import type { SettingsContextProps } from "@homarr/settings/creator";
import { SpotlightProvider } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
import { zodErrorMap } from "@homarr/validation/form/i18n"; import { zodErrorMap } from "@homarr/validation/form/i18n";
@@ -41,13 +21,12 @@ import { getInputForType } from "../_inputs";
import { FormProvider, useForm } from "../_inputs/form"; import { FormProvider, useForm } from "../_inputs/form";
import type { BoardItemAdvancedOptions } from "../../../validation/src/shared"; import type { BoardItemAdvancedOptions } from "../../../validation/src/shared";
import type { OptionsBuilderResult } from "../options"; import type { OptionsBuilderResult } from "../options";
import type { WidgetComponentProps, WidgetDefinition } from "../definition"; import type { WidgetDefinition } from "../definition";
import { WidgetError } from "../errors/component";
import { OPTIONS_SUPER_REFINE } from "../options"; import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select"; import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } 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 { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
import { useWidgetEditFocus, WidgetEditFocusOverlay } from "./widget-edit-focus-overlay";
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form"; import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
import { EmbeddedAppEditForm } from "./embedded-app-edit-form"; import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
import classes from "./widget-edit-modal.module.css"; import classes from "./widget-edit-modal.module.css";
@@ -58,17 +37,6 @@ export interface WidgetEditModalState {
advancedOptions: BoardItemAdvancedOptions; 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 { export interface EmbeddedIntegrationEditFormHandle {
submitIfDirty: () => Promise<boolean>; submitIfDirty: () => Promise<boolean>;
} }
@@ -83,225 +51,21 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
kind: TSort; kind: TSort;
definition: WidgetDefinition; definition: WidgetDefinition;
value: WidgetEditModalState; value: WidgetEditModalState;
onSuccessfulEdit: (value: WidgetEditModalState, size?: WidgetEditModalSize) => void; onSuccessfulEdit: (value: WidgetEditModalState) => void;
onPreviewChange?: (value: WidgetEditModalState) => void;
onPreviewRestore?: () => void;
integrationData: IntegrationSelectOption[]; integrationData: IntegrationSelectOption[];
integrationSupport: boolean; integrationSupport: boolean;
settings: SettingsContextProps; settings: SettingsContextProps;
itemId?: string; itemId?: string;
boardId?: string; boardId?: string;
focusTargetId?: string;
appId?: string; appId?: string;
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>; integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
onIntegrationSaved?: () => void; onIntegrationSaved?: () => void;
onOpenNewIntegration?: (onCreated?: (id: string) => void) => void; onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
previewComponent?: ComponentType<WidgetComponentProps<TSort>>;
previewDimensions?: { width: number; height: number; scale?: number };
previewResize?: WidgetEditPreviewResizeOptions;
previewWrapper?: ComponentType<PropsWithChildren>;
} }
interface WidgetEditPreviewProps {
kind: WidgetKind;
Component: ComponentType<WidgetComponentProps<WidgetKind>>;
definition: WidgetDefinition;
state: WidgetEditModalState;
itemId?: string;
boardId?: string;
dimensions?: { width: number; height: number; scale?: number };
integrationData: IntegrationSelectOption[];
onChangeOptions: (newOptions: Record<string, unknown>) => void;
resize?: {
size: WidgetEditModalSize;
maximumSize: WidgetEditModalSize;
onChange: (size: WidgetEditModalSize) => void;
};
PreviewWrapper?: ComponentType<PropsWithChildren>;
}
const PreviewRuntimeBoundary = ({
Wrapper,
children,
}: PropsWithChildren<{ Wrapper?: ComponentType<PropsWithChildren> }>) => {
if (!Wrapper) return children;
return <Wrapper>{children}</Wrapper>;
};
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<HTMLDivElement>();
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 (
<Stack className={classes.previewPanel} gap={0}>
<Center ref={ref} className={classes.previewCanvas}>
{resize && (
<Group className={classes.previewSizeControls} gap={6} wrap="nowrap">
<Text size="xs" fw={600} c="dimmed">
{tItem("preview.size")}
</Text>
<Tooltip label={t("item.moveResize.field.width.label")}>
<NumberInput
className={classes.previewSizeInput}
aria-label={t("item.moveResize.field.width.label")}
value={resize.size.width}
onChange={(value) => 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"
/>
</Tooltip>
<Text size="xs" c="dimmed" aria-hidden>
×
</Text>
<Tooltip label={t("item.moveResize.field.height.label")}>
<NumberInput
className={classes.previewSizeInput}
aria-label={t("item.moveResize.field.height.label")}
value={resize.size.height}
onChange={(value) => 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"
/>
</Tooltip>
</Group>
)}
<Badge className={classes.previewDimensions} size="xs" variant="light" color="gray">
{Math.round(sourceWidth)} × {Math.round(sourceHeight)}
</Badge>
{isMissingIntegration ? (
<Alert color="gray" variant="light" icon={<IconEye size={18} />} maw={320}>
{tItem("preview.integrationRequired")}
</Alert>
) : (
<Box className={classes.previewViewport} w={previewWidth} h={previewHeight}>
<WidgetCardShell
className={classes.previewWidget}
kind={kind}
advancedOptions={state.advancedOptions}
opacity={previewOpacity}
radius={board?.itemRadius}
w={sourceWidth}
h={sourceHeight}
p={0}
data-grid-item-content
style={{ transform: `scale(${previewScale})` }}
>
<WidgetTitleBadge
advancedOptions={state.advancedOptions}
opacity={previewOpacity}
radius={board?.itemRadius}
/>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
resetKeys={[state.options, state.integrationIds]}
fallbackRender={({ resetErrorBoundary, error }) => (
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
)}
>
<PreviewRuntimeBoundary Wrapper={PreviewWrapper}>
<SpotlightProvider>
<IntegrationProvider integrations={previewIntegrations}>
<div className={classes.previewContent} inert>
<Component
options={state.options as never}
integrationIds={state.integrationIds}
width={sourceWidth}
height={sourceHeight}
isEditMode={isPendingCustomWidget}
displayMode="compact"
boardId={boardId}
itemId={componentItemId}
setOptions={({ newOptions }) => onChangeOptions(newOptions as Record<string, unknown>)}
/>
</div>
</IntegrationProvider>
</SpotlightProvider>
</PreviewRuntimeBoundary>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</WidgetCardShell>
</Box>
)}
</Center>
</Stack>
);
};
export const getSelectedWidgetIntegrations = ( export const getSelectedWidgetIntegrations = (
integrationData: readonly IntegrationSelectOption[], integrationData: readonly IntegrationSelectOption[],
selectedIds: readonly string[], selectedIds: readonly string[],
@@ -322,12 +86,18 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]); const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null); const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [previewSize, setPreviewSize] = useState<WidgetEditModalSize | null>(() => { const hasPreviewedRef = useRef(false);
const resize = innerProps.previewResize; const hasSavedRef = useRef(false);
if (!resize) return null; const previewCallbacksRef = useRef({
return normalizePreviewSize(resize.initialSize, resize.maximumSize); onPreviewChange: innerProps.onPreviewChange,
onPreviewRestore: innerProps.onPreviewRestore,
}); });
previewCallbacksRef.current = {
onPreviewChange: innerProps.onPreviewChange,
onPreviewRestore: innerProps.onPreviewRestore,
};
const widgetFormId = useId(); const widgetFormId = useId();
const { focusRect, inspectorSide } = useWidgetEditFocus(innerProps.itemId, innerProps.focusTargetId);
z.config({ z.config({
customError: zodErrorMap(t), customError: zodErrorMap(t),
@@ -359,25 +129,47 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1); if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1);
if (Number.isFinite(maxIntegrations)) integrationIdsSchema = integrationIdsSchema.max(maxIntegrations); if (Number.isFinite(maxIntegrations)) integrationIdsSchema = integrationIdsSchema.max(maxIntegrations);
const widgetFormSchema = z.object({
options: optionsSchema,
integrationIds: integrationIdsSchema,
});
const applyPreview = (value: WidgetEditModalState) => {
if (!innerProps.onPreviewChange) return;
hasPreviewedRef.current = true;
innerProps.onPreviewChange(value);
};
const form = useForm({ const form = useForm({
mode: "controlled", mode: "controlled",
initialValues: innerProps.value, initialValues: {
validate: schemaResolver( options: innerProps.value.options,
z.object({ integrationIds: innerProps.value.integrationIds,
options: optionsSchema, },
integrationIds: integrationIdsSchema, validate: schemaResolver(widgetFormSchema, { sync: true }),
advancedOptions: z.object({
customCssClasses: z.array(z.string()),
borderColor: z.string(),
}),
}),
{ sync: true },
),
validateInputOnBlur: true, validateInputOnBlur: true,
validateInputOnChange: true, validateInputOnChange: true,
onValuesChange: (values) => {
if (!widgetFormSchema.safeParse(values).success) return;
applyPreview({ ...values, advancedOptions });
},
}); });
const { openModal } = useModalAction(WidgetAdvancedOptionsModal); const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
useEffect(
() => () => {
if (!hasPreviewedRef.current || hasSavedRef.current) return;
const { onPreviewChange, onPreviewRestore } = previewCallbacksRef.current;
if (onPreviewRestore) {
onPreviewRestore();
return;
}
onPreviewChange?.(innerProps.value);
},
[innerProps.value],
);
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false; const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const appId = innerProps.appId; const appId = innerProps.appId;
@@ -453,13 +245,11 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
} }
} }
innerProps.onSuccessfulEdit( innerProps.onSuccessfulEdit({
{ ...values,
...values, advancedOptions,
advancedOptions, });
}, hasSavedRef.current = true;
previewSize ?? undefined,
);
actions.closeModal(); actions.closeModal();
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -481,272 +271,202 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
} }
: undefined; : undefined;
const handlePreviewOptionsChange = (newOptions: Record<string, unknown>) => {
form.setFieldValue("options", {
...form.values.options,
...newOptions,
});
};
const selectedIntegrationKinds = innerProps.integrationData const selectedIntegrationKinds = innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id)) .filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind); .map(({ kind }) => kind);
const visibleOptions = Object.entries(options).filter(([, value]) => { const visibleOptions = Object.entries(options).filter(([, value]) => {
const Input = getInputForType(value.type); const Input = getInputForType(value.type);
if (!Input) { if (!Input) return false;
return false;
}
return !value.shouldHide?.(form.values.options as never, selectedIntegrationKinds); return !value.shouldHide?.(form.values.options as never, selectedIntegrationKinds);
}); });
const switchOptions = visibleOptions.filter(([, value]) => value.type === "switch"); const switchOptions = visibleOptions.filter(([, value]) => value.type === "switch");
const otherOptions = 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 = ( const widgetFormContent = (
<Box <form id={widgetFormId} onSubmit={onFormSubmit}>
className={classes.workspace} <Stack className={classes.settings} gap="md">
data-with-preview={innerProps.previewComponent ? true : undefined} {canConfigureWidget && innerProps.integrationSupport && (
data-with-settings={hasWidgetSettings ? true : undefined} <WidgetIntegrationSelect
> label={tItem("field.integrations.label")}
{innerProps.previewComponent && ( data={innerProps.integrationData}
<WidgetEditPreview canSelectMultiple={maxIntegrations > 1}
kind={innerProps.kind} withAsterisk={integrationsRequired}
Component={innerProps.previewComponent} onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
definition={definition} {...form.getInputProps("integrationIds")}
state={{ ...form.values, advancedOptions }} />
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
dimensions={previewDimensions}
integrationData={innerProps.integrationData}
onChangeOptions={handlePreviewOptionsChange}
resize={resizeControls}
PreviewWrapper={innerProps.previewWrapper}
/>
)}
{innerProps.previewComponent && hasWidgetSettings && (
<Divider orientation="vertical" className={classes.previewDivider} />
)}
<form id={widgetFormId} className={classes.settingsForm} onSubmit={onFormSubmit} hidden={!hasWidgetSettings}>
{hasWidgetSettings && (
<Stack className={classes.settingsPanel} gap="sm">
<Group gap="xs" wrap="nowrap">
<IconSettings size={16} />
<Text fw={600} size="sm">
{tItem("settings.label")}
</Text>
</Group>
{canConfigureWidget && innerProps.integrationSupport && (
<Box className={classes.fullWidthField}>
<WidgetIntegrationSelect
label={tItem("field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
</Box>
)}
{canConfigureWidget && (
<Stack gap="sm">
{switchOptions.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm" verticalSpacing="sm">
{switchOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
return (
<Box
key={key}
className={classes.switchOption}
data-option-type={value.type}
onClick={(event) => {
const target = event.target;
if (target instanceof Element && target.closest("label, input")) return;
const input = event.currentTarget.querySelector<HTMLInputElement>("input[type=checkbox]");
if (!input?.disabled) input?.click();
}}
>
<Input
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
</Box>
);
})}
</SimpleGrid>
)}
{otherOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
return (
<Box key={key} data-option-type={value.type}>
<Input
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
</Box>
);
})}
</Stack>
)}
</Stack>
)} )}
</form> {canConfigureWidget && switchOptions.length > 0 && (
</Box> <Box className={classes.switchGrid}>
); {switchOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) return null;
const footer = ( return (
<Box className={classes.footer}> <Box key={key} className={classes.switchOption}>
<Group justify="space-between" wrap="nowrap"> <Input
<Button kind={innerProps.kind}
variant="subtle" property={key}
type="button" options={value as never}
onClick={() => initialOptions={innerProps.value.options}
openModal({ itemId={innerProps.itemId}
advancedOptions, boardId={innerProps.boardId ?? board?.id}
onSuccess: setAdvancedOptions, />
}) </Box>
} );
> })}
{tItem("advancedOptions.label")} </Box>
</Button> )}
<Group gap="xs" wrap="nowrap"> {canConfigureWidget &&
<Button onClick={actions.closeModal} variant="subtle" color="gray"> otherOptions.map(([key, value]) => {
{tCommon("action.cancel")} const Input = getInputForType(value.type);
</Button> if (!Input) return null;
<Button type="submit" form={widgetFormId} loading={isSubmitting}>
{tCommon("action.saveChanges")} return (
</Button> <Input
</Group> key={key}
</Group> kind={innerProps.kind}
</Box> property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
);
})}
</Stack>
</form>
); );
return ( return (
<FormProvider form={form}> <>
<Stack gap="sm"> <WidgetEditFocusOverlay focusRect={focusRect} />
{showResourceTabs ? ( <FormProvider form={form}>
<Tabs value={activeTab} onChange={setActiveTab}> <Stack className={classes.panel} data-widget-inspector-side={inspectorSide} gap={0}>
<Tabs.List grow> <Box pb="md">
<Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab> {showResourceTabs ? (
{showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>} <Tabs value={activeTab} onChange={setActiveTab}>
{showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>} <Tabs.List grow>
</Tabs.List> <Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab>
<Tabs.Panel value="widget" pt="sm"> {showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>}
{widgetFormContent} {showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>}
</Tabs.Panel> </Tabs.List>
{showAppTab && ( <Tabs.Panel value="widget" pt="md">
<Tabs.Panel value="app" pt="sm"> {widgetFormContent}
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />} </Tabs.Panel>
</Tabs.Panel> {showAppTab && (
)} <Tabs.Panel value="app" pt="md">
{showIntegrationTab && ( {appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
<Tabs.Panel value="integration" pt="sm"> </Tabs.Panel>
<Stack> )}
{displayedIntegrationId === null && ( {showIntegrationTab && (
<> <Tabs.Panel value="integration" pt="md">
<Text size="sm" c="dimmed"> <Stack>
{tItem("integration.description")} {displayedIntegrationId === null && (
</Text> <>
{selectedIntegrations.map((integration) => { <Text size="sm" c="dimmed">
const canEdit = editableIntegrationIds.includes(integration.id); {tItem("integration.description")}
return ( </Text>
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm"> {selectedIntegrations.map((integration) => {
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}> const canEdit = editableIntegrationIds.includes(integration.id);
<IntegrationAvatar kind={integration.kind} size="sm" /> return (
<Stack gap={0} style={{ minWidth: 0 }}> <Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Text fw={600} size="sm" truncate> <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
{integration.name} <IntegrationAvatar kind={integration.kind} size="sm" />
</Text> <Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed" truncate> <Text fw={600} size="sm" truncate>
{integration.url} {integration.name}
</Text> </Text>
</Stack> <Text size="xs" c="dimmed" truncate>
</Group> {integration.url}
{canEdit ? ( </Text>
<Button </Stack>
type="button" </Group>
variant="light" {canEdit ? (
leftSection={<IconPencil size={16} />} <Button
onClick={() => beginEditingIntegration(integration.id)} type="button"
aria-label={tItem("integration.editLabel", { name: integration.name })} variant="light"
> leftSection={<IconPencil size={16} />}
{tItem("integration.action")} onClick={() => beginEditingIntegration(integration.id)}
</Button> aria-label={tItem("integration.editLabel", { name: integration.name })}
) : ( >
<Text size="xs" c="dimmed" ta="end"> {tItem("integration.action")}
{tItem("integration.fullAccessRequired")} </Button>
</Text> ) : (
)} <Text size="xs" c="dimmed" ta="end">
</Group> {tItem("integration.fullAccessRequired")}
); </Text>
})} )}
</> </Group>
)} );
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && ( })}
<Button </>
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)} )}
</Box> {displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
))} <Button
</Stack> type="button"
</Tabs.Panel> variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack>
</Tabs.Panel>
)}
</Tabs>
) : (
widgetFormContent
)} )}
</Tabs> </Box>
) : ( <Group className={classes.footer} justify="space-between" wrap="wrap">
widgetFormContent <Button
)} variant="subtle"
{footer} type="button"
</Stack> onClick={() =>
</FormProvider> openModal({
advancedOptions,
onSuccess: (value) => {
setAdvancedOptions(value);
applyPreview({ ...form.getValues(), advancedOptions: value });
},
})
}
>
{tItem("advancedOptions.label")}
</Button>
<Group className={classes.footerActions} gap="xs" justify="flex-end" wrap="wrap">
<Button type="button" onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" form={widgetFormId} loading={isSubmitting}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Group>
</Stack>
</FormProvider>
</>
); );
}).withOptions({ }).withOptions({
keepMounted: true, keepMounted: true,
defaultTitle(t) { defaultTitle(t) {
return t("item.edit.title"); return t("item.edit.title");
}, },
presentation: "inspector",
withOverlay: false,
size: modalSizeForm, size: modalSizeForm,
closeOnClickOutside: false, closeOnClickOutside: false,
}); });
@@ -1,34 +0,0 @@
.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;
}
@@ -1,95 +0,0 @@
"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<Omit<CardProps, "className" | "styles">> {
kind: WidgetKind;
advancedOptions: BoardItemAdvancedOptions;
opacity: number;
className?: string;
innerRef?: Ref<HTMLDivElement>;
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) => (
<Card
{...cardProps}
ref={innerRef}
className={combineClasses(
classes.card,
kind === "iframe" && classes.iframe,
`${kind}-wrapper`,
"board-grid-item-content",
advancedOptions.customCssClasses.join(" "),
className,
)}
styles={{
root: {
"--opacity": opacity,
"--border-color": advancedOptions.borderColor || undefined,
containerType: "size",
...getOverflow(kind, advancedOptions.customCssClasses.length > 0),
} as CSSProperties,
}}
>
{children}
</Card>
);
interface WidgetTitleBadgeProps {
advancedOptions: BoardItemAdvancedOptions;
opacity: number;
radius: CardProps["radius"];
}
export const WidgetTitleBadge = ({ advancedOptions, opacity, radius }: WidgetTitleBadgeProps) => {
if (!advancedOptions.title?.trim()) return null;
return (
<Badge
pos="absolute"
style={{ zIndex: "var(--mantine-z-index-app)" }}
top={2}
left={16}
size="xs"
radius={radius}
styles={{
root: {
"--border-color": advancedOptions.borderColor || undefined,
"--opacity": opacity,
} as CSSProperties,
}}
className={classes.title}
c="var(--mantine-color-text)"
>
{advancedOptions.title}
</Badge>
);
};