Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aa667b8cc | ||
|
|
d3192688c6 | ||
|
|
11626eda8a | ||
|
|
f58c0ac77c | ||
|
|
b3e24f0d34 | ||
|
|
7d742c9efa |
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.
|
||||||
|
|||||||
|
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);
|
||||||
|
|||||||
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 589 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 651 KiB |
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,7 @@ export type CreateModalOptions = Pick<
|
|||||||
| "centered"
|
| "centered"
|
||||||
| "keepMounted"
|
| "keepMounted"
|
||||||
| "withCloseButton"
|
| "withCloseButton"
|
||||||
|
| "withOverlay"
|
||||||
| "zIndex"
|
| "zIndex"
|
||||||
| "scrollAreaComponent"
|
| "scrollAreaComponent"
|
||||||
| "yOffset"
|
| "yOffset"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||