fix(board): fit preview to panel, keep app selection on search, keep checklist under modals

- Scale oversized widget previews to fit the 320px panel
- Derive selected apps from all apps so search doesn't drop selections
- Drop checklist Affix z-index override so modals stay on top
- Always render preview panel (placeholder state) and add custom widget previews
- Hide preview column below lg breakpoint to avoid crushing the grid
This commit is contained in:
Thomas Camlong committed 2026-08-17 11:22:23 +02:00
1 parent f10e49fa20
commit 6d6815a901
5 files changed
+119 -50

No files matched your search

@@ -35,7 +35,7 @@ export const BoardSetupChecklist = () => {
if (collapsed) {
return (
<Affix position={{ top: appShellHeaderHeight + 16, left: 16 }} className={classes.root} withinPortal zIndex={300}>
<Affix position={{ top: appShellHeaderHeight + 16, left: 16 }} className={classes.root}>
<Button
variant="default"
leftSection={<IconChecklist size={18} />}
@@ -51,7 +51,7 @@ export const BoardSetupChecklist = () => {
}
return (
<Affix position={{ top: appShellHeaderHeight + 16, left: 16 }} className={classes.root} withinPortal zIndex={300}>
<Affix position={{ top: appShellHeaderHeight + 16, left: 16 }} className={classes.root}>
<Paper className={classes.panel} withBorder shadow="md" radius="md" p="md">
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
@@ -66,6 +66,8 @@ interface ItemSelectModalContentProps {
initialSearch?: string;
}
type PreviewState = { kind: WidgetKind; options?: Record<string, unknown>; label?: string } | null;
const ItemSelectModalContent = ({
actions,
integrationData,
@@ -111,7 +113,7 @@ const ItemSelectModalContent = ({
);
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
const [previewKind, setPreviewKind] = useState<WidgetKind | null>(null);
const [preview, setPreview] = useState<PreviewState>(null);
const selectionLock = useRef(false);
const { createItem, removeItem } = useItemActions();
@@ -132,13 +134,31 @@ const ItemSelectModalContent = ({
);
const previewIntegrationIds = useMemo(() => {
if (!previewKind) return [];
const supported = (widgetIntegrationSupport[previewKind] ?? []).filter((integration) => integration !== "mock");
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, previewKind]);
}, [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(
() =>
@@ -423,12 +443,12 @@ const ItemSelectModalContent = ({
onFocus={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
setPreviewKind(item.kind);
setPreview({ kind: item.kind });
}}
onPointerEnter={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
setPreviewKind(item.kind);
setPreview({ kind: item.kind });
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
@@ -457,8 +477,14 @@ const ItemSelectModalContent = ({
disabled={loadingSelection !== null}
busy={loadingSelection === `custom:${def.id}`}
onSelect={() => void handleAddCustomWidget(def)}
onFocus={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
onFocus={() => {
void loadWidgetDefinition("customApi").catch(() => undefined);
handlePreviewCustomWidget(def);
}}
onPointerEnter={() => {
void loadWidgetDefinition("customApi").catch(() => undefined);
handlePreviewCustomWidget(def);
}}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
@@ -492,11 +518,14 @@ const ItemSelectModalContent = ({
)}
</SelectGridLayout>
</Box>
{previewKind && (
<Box w={320} style={{ flexShrink: 0 }}>
<WidgetPreviewPanel kind={previewKind} integrationIds={previewIntegrationIds} />
</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>
);
};
@@ -1,7 +1,8 @@
"use client";
import { Suspense, use } from "react";
import { Center, Loader, Paper, Stack, Text } from "@mantine/core";
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";
@@ -12,61 +13,102 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetError } from "@homarr/widgets/errors";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
const PREVIEW_CELL_SIZE = 56;
const PREVIEW_MIN_HEIGHT = 120;
const PREVIEW_MAX_HEIGHT = 260;
const PANEL_WIDTH = 320;
export const getPreviewSize = (kind: WidgetKind) => {
const defaultSize = widgetDefaultSizes[kind] ?? { width: 1, height: 1 };
return {
width: Math.max(140, defaultSize.width * PREVIEW_CELL_SIZE),
height: Math.min(PREVIEW_MAX_HEIGHT, Math.max(PREVIEW_MIN_HEIGHT, defaultSize.height * PREVIEW_CELL_SIZE)),
};
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 };
};
interface WidgetPreviewProps {
kind: WidgetKind;
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 }: WidgetPreviewProps) => {
export const WidgetPreviewPanel = ({ kind, integrationIds, options, label }: WidgetPreviewPanelProps) => {
const t = useI18n();
const { width, height } = getPreviewSize(kind);
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">
{t("item.create.preview.label")}
<Text size="xs" c="dimmed" mb="xs" fw={600} tt="uppercase" truncate>
{label ?? t("item.create.preview.label")}
</Text>
<Center h={`calc(100% - 26px)`} style={{ pointerEvents: "none" }}>
<Suspense
fallback={
<Center h={height}>
<Loader size="sm" />
</Center>
}
>
<WidgetPreviewContent kind={kind} integrationIds={integrationIds} width={width} height={height} />
</Suspense>
</Center>
{kind ? <PreviewContent kind={kind} integrationIds={integrationIds} options={options} /> : <Placeholder />}
</Paper>
);
};
const WidgetPreviewContent = ({
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 options = reduceWidgetOptionsWithDefinition(definition, settings, {});
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings, {});
const mergedOptions = { ...defaultOptions, ...options };
return (
<QueryErrorResetBoundary>
@@ -79,7 +121,7 @@ const WidgetPreviewContent = ({
>
<Stack w={width} h={height} style={{ overflow: "hidden" }}>
<Component
options={options as never}
options={mergedOptions as never}
integrationIds={integrationIds}
width={width}
height={height}
@@ -34,10 +34,7 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
[apps, search],
);
const selectedApps = useMemo(
() => filteredApps.filter((app) => selectedAppIds.has(app.id)),
[filteredApps, selectedAppIds],
);
const selectedApps = useMemo(() => apps.filter((app) => selectedAppIds.has(app.id)), [apps, selectedAppIds]);
const handleSelect = (app: SelectableApp) => {
if (multiSelect) {
+2 -1
View File
@@ -2751,7 +2751,8 @@
}
},
"preview": {
"label": "Live preview"
"label": "Live preview",
"placeholder": "Select a widget to preview its appearance"
}
},
"moveResize": {