feat(board): remove live widget preview, streamline integration editing

- Drop the narrow live preview panel from the widget picker (it kept
  rendering oversized widgets with errors) along with its translations
- The Edit integration tab now opens the connection editor directly when
  a widget has exactly one integration
- The connection editor closes on outside click
- Keep the save/cancel footer floating at the bottom of the editor
This commit is contained in:
Thomas Camlong committed 2026-08-17 16:21:44 +02:00
1 parent 482a8c20e3
commit c6d6f43c85
6 files changed
+36 -197

No files matched your search

@@ -44,7 +44,6 @@ import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { WidgetPreviewPanel } from "./widget-preview-panel";
import { useItemActions } from "./item-actions";
import type { WidgetConnectionStatus } from "./item-select-data";
import {
@@ -65,8 +64,6 @@ interface ItemSelectModalContentProps {
initialSearch?: string;
}
type PreviewState = { kind: WidgetKind; options?: Record<string, unknown>; label?: string } | null;
const ItemSelectModalContent = ({
actions,
integrationData,
@@ -112,7 +109,6 @@ const ItemSelectModalContent = ({
);
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const [preview, setPreview] = useState<PreviewState>(null);
const selectionLock = useRef(false);
const { createItem, removeItem } = useItemActions();
@@ -132,33 +128,6 @@ const ItemSelectModalContent = ({
[integrationData],
);
const previewIntegrationIds = useMemo(() => {
if (!preview?.kind) return [];
const supported = (widgetIntegrationSupport[preview.kind] ?? []).filter((integration) => integration !== "mock");
return (integrationData ?? [])
.filter(({ permissions }) => permissions.hasUseAccess)
.filter(({ kind }) => (supported as readonly string[]).includes(kind))
.map(({ id }) => id);
}, [integrationData, preview]);
const handlePreviewCustomWidget = (customWidgetDefinition: NonNullable<typeof customWidgetDefs>[number]) => {
void loadWidgetDefinition("customApi")
.then((definition) => {
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
setPreview({
kind: "customApi",
label: customWidgetDefinition.name,
options: {
...defaultOptions,
definitionId: customWidgetDefinition.id,
configuration: customWidgetDefinition.defaultOptions,
configurationVersion: customWidgetDefinition.updatedAt.getTime(),
},
});
})
.catch(() => undefined);
};
const items = useMemo(
() =>
widgetKinds
@@ -442,12 +411,10 @@ const ItemSelectModalContent = ({
onFocus={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
setPreview({ kind: item.kind });
}}
onPointerEnter={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
setPreview({ kind: item.kind });
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
@@ -478,11 +445,9 @@ const ItemSelectModalContent = ({
onSelect={() => void handleAddCustomWidget(def)}
onFocus={() => {
void loadWidgetDefinition("customApi").catch(() => undefined);
handlePreviewCustomWidget(def);
}}
onPointerEnter={() => {
void loadWidgetDefinition("customApi").catch(() => undefined);
handlePreviewCustomWidget(def);
}}
>
<Stack h="100%" gap="xs">
@@ -528,14 +493,6 @@ const ItemSelectModalContent = ({
)}
</SelectGridLayout>
</Box>
<Box visibleFrom="lg" w={320} style={{ flexShrink: 0 }}>
<WidgetPreviewPanel
kind={preview?.kind ?? null}
integrationIds={previewIntegrationIds}
options={preview?.options ?? undefined}
label={preview?.label ?? undefined}
/>
</Box>
</Flex>
);
};
@@ -79,7 +79,7 @@ const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
closeOnClickOutside: true,
});
interface LazyWidgetEditModalContentProps {
@@ -1,138 +0,0 @@
"use client";
import { Suspense, use } from "react";
import { Center, Loader, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconZoomIn } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
import type { WidgetKind } from "@homarr/definitions";
import { widgetDefaultSizes } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { WidgetError } from "@homarr/widgets/errors";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
const PANEL_WIDTH = 320;
const MAX_PREVIEW_HEIGHT = 260;
const fitInBounds = (minHeight: number, width: number, height: number, maxWidth: number, maxHeight: number) => {
const scale = Math.min(maxWidth / width, maxHeight / height, 1);
const scaledWidth = Math.min(Math.max(width * scale, 160), maxWidth);
const scaledHeight = Math.min(Math.max(height * scale, minHeight), maxHeight);
return { width: scaledWidth, height: scaledHeight };
};
export const getPreviewSize = (kind: WidgetKind) => {
const base = widgetDefaultSizes[kind] ?? { width: 1, height: 1 };
return fitInBounds(140, base.width * 56, base.height * 56, PANEL_WIDTH - 32, MAX_PREVIEW_HEIGHT);
};
interface WidgetPreviewPanelProps {
kind: WidgetKind | null;
integrationIds: string[];
options?: Record<string, unknown>;
label?: string;
}
export const WidgetPreviewPanel = ({ kind, integrationIds, options, label }: WidgetPreviewPanelProps) => {
const t = useI18n();
return (
<Paper withBorder radius="md" p="sm" shadow="sm" h="100%" style={{ overflow: "hidden" }}>
<Text size="xs" c="dimmed" mb="xs" fw={600} tt="uppercase" truncate>
{label ?? t("item.create.preview.label")}
</Text>
{kind ? <PreviewContent kind={kind} integrationIds={integrationIds} options={options} /> : <Placeholder />}
</Paper>
);
};
const Placeholder = () => {
const t = useI18n();
return (
<Center h="calc(100% - 26px)" c="dimmed">
<Stack gap="xs" align="center">
<ThemeIcon variant="light" size="lg" radius="xl">
<IconZoomIn size={20} />
</ThemeIcon>
<Text size="sm" c="dimmed" ta="center" px="md">
{t("item.create.preview.placeholder")}
</Text>
</Stack>
</Center>
);
};
const PreviewContent = ({
kind,
integrationIds,
options,
}: {
kind: WidgetKind;
integrationIds: string[];
options?: Record<string, unknown>;
}) => {
const { width, height } = getPreviewSize(kind);
return (
<Center h="calc(100% - 26px)" style={{ pointerEvents: "none" }}>
<Suspense
fallback={
<Center h={height}>
<Loader size="sm" />
</Center>
}
>
<InnerPreview kind={kind} integrationIds={integrationIds} options={options} width={width} height={height} />
</Suspense>
</Center>
);
};
const InnerPreview = ({
kind,
integrationIds,
options,
width,
height,
}: {
kind: WidgetKind;
integrationIds: string[];
options?: Record<string, unknown>;
width: number;
height: number;
}) => {
const settings = useSettings();
const { definition, Component } = use(loadWidgetResources(kind));
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings, {});
const mergedOptions = { ...defaultOptions, ...options };
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
)}
>
<Stack w={width} h={height} style={{ overflow: "hidden" }}>
<Component
options={mergedOptions as never}
integrationIds={integrationIds}
width={width}
height={height}
isEditMode={false}
boardId={undefined}
itemId={undefined}
setOptions={() => undefined}
/>
</Stack>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
};
@@ -82,5 +82,5 @@ export const EmbeddedIntegrationEditModal = createModal<EmbeddedIntegrationEditM
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
closeOnClickOutside: true,
});
-4
View File
@@ -2762,10 +2762,6 @@
"title": "Unable to add item",
"message": "The item could not be added to this board"
}
},
"preview": {
"label": "Live preview",
"placeholder": "Select a widget to preview its appearance"
}
},
"moveResize": {
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { Button, Group, Stack, Tabs, Text } from "@mantine/core";
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
import { schemaResolver } from "@mantine/form";
import { IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
@@ -127,6 +127,17 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
if (activeTab === "app" && !showAppTab) setActiveTab("widget");
}, [activeTab, showAppTab, showIntegrationTab]);
const handleTabChange = (value: string | null) => {
if (value === "integration" && selectedIntegrations.length === 1) {
const integration = selectedIntegrations[0];
if (integration) {
innerProps.onEditIntegration?.(integration.id);
return;
}
}
setActiveTab(value);
};
const handleSubmit = form.onSubmit(async (values) => {
setIsSubmitting(true);
try {
@@ -229,7 +240,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
<FormProvider form={form}>
{showResourceTabs ? (
<Stack>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs value={activeTab} onChange={handleTabChange}>
<Tabs.List grow>
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
@@ -286,14 +297,27 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
</Tabs.Panel>
)}
</Tabs>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isSubmitting}>
{t("common.action.saveChanges")}
</Button>
</Group>
<Box
pos="sticky"
bottom={0}
style={{
marginInline: "calc(var(--mantine-spacing-md) * -1)",
marginBottom: "calc(var(--mantine-spacing-md) * -1)",
paddingInline: "var(--mantine-spacing-md)",
paddingBlock: "var(--mantine-spacing-sm)",
background: "var(--mantine-color-body)",
borderTop: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isSubmitting}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Box>
</Stack>
) : (
widgetFormContent