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:
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,
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user