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:
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) {
|
||||
|
||||
@@ -2751,7 +2751,8 @@
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"label": "Live preview"
|
||||
"label": "Live preview",
|
||||
"placeholder": "Select a widget to preview its appearance"
|
||||
}
|
||||
},
|
||||
"moveResize": {
|
||||
|
||||
Reference in new issue
Block a user