feat(board): add widget preview, floating checklist and multi-select apps

- Show a live widget preview panel next to the grid in the add-widget modal,
  sizing from the widget default size and using matching integrations
- Pin the board setup checklist below the app header with Affix
- Allow selecting and adding multiple apps at once from the board
This commit is contained in:
Thomas Camlong committed 2026-08-17 09:03:07 +02:00
1 parent 8ecdd13cd5
commit f10e49fa20
7 files changed
+375 -173

No files matched your search

@@ -1,10 +1,6 @@
.root {
position: absolute;
z-index: 4;
bottom: calc(var(--mantine-spacing-md) * var(--board-canvas-inverse-scale, 1));
left: calc(var(--mantine-spacing-md) * var(--board-canvas-inverse-scale, 1));
max-width: min(24rem, calc(100% - 2 * var(--mantine-spacing-md)));
pointer-events: auto;
max-width: min(24rem, calc(100vw - 2rem));
}
.panel {
@@ -1,6 +1,6 @@
"use client";
import { ActionIcon, Button, Group, Paper, Progress, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { ActionIcon, Affix, Button, Group, Paper, Progress, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconCheck, IconChevronDown, IconChecklist } from "@tabler/icons-react";
@@ -10,6 +10,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import { appShellHeaderHeight } from "~/components/layout/constants";
import { getBoardSetupProgress } from "./board-setup-progress";
import { useBoardAddActions } from "./use-board-add-actions";
import classes from "./board-setup-checklist.module.css";
@@ -34,7 +35,7 @@ export const BoardSetupChecklist = () => {
if (collapsed) {
return (
<div className={classes.root}>
<Affix position={{ top: appShellHeaderHeight + 16, left: 16 }} className={classes.root} withinPortal zIndex={300}>
<Button
variant="default"
leftSection={<IconChecklist size={18} />}
@@ -45,12 +46,12 @@ export const BoardSetupChecklist = () => {
>
{t("resume", { completed: progress.completedCount, total: progress.totalCount })}
</Button>
</div>
</Affix>
);
}
return (
<div className={classes.root}>
<Affix position={{ top: appShellHeaderHeight + 16, left: 16 }} className={classes.root} withinPortal zIndex={300}>
<Paper className={classes.panel} withBorder shadow="md" radius="md" p="md">
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
@@ -93,7 +94,7 @@ export const BoardSetupChecklist = () => {
/>
</Stack>
</Paper>
</div>
</Affix>
);
};
@@ -1,5 +1,19 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Avatar, Badge, Box, Button, Center, Divider, Group, Image, Select, Stack, Text, Tooltip } from "@mantine/core";
import {
Avatar,
Badge,
Box,
Button,
Center,
Divider,
Flex,
Group,
Image,
Select,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconApi } from "@tabler/icons-react";
@@ -31,6 +45,7 @@ import { WorkshopInstallButton } from "~/components/workshop/workshop-install-bu
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 {
@@ -96,6 +111,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 selectionLock = useRef(false);
const { createItem, removeItem } = useItemActions();
@@ -115,6 +131,15 @@ const ItemSelectModalContent = ({
[integrationData],
);
const previewIntegrationIds = useMemo(() => {
if (!previewKind) return [];
const supported = (widgetIntegrationSupport[previewKind] ?? []).filter((integration) => integration !== "mock");
return (integrationData ?? [])
.filter(({ permissions }) => permissions.hasUseAccess)
.filter(({ kind }) => (supported as readonly string[]).includes(kind))
.map(({ id }) => id);
}, [integrationData, previewKind]);
const items = useMemo(
() =>
widgetKinds
@@ -364,100 +389,115 @@ const ItemSelectModalContent = ({
};
return (
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("item.create.search")}...`}
ariaLabel={t("item.create.search")}
onSearchKeyDown={(event) => {
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
const [item] = filteredItems;
if (item) void handleAdd(item.kind);
}
}}
>
{placementOptions.length > 1 && (
<Box style={{ gridColumn: "1 / -1" }}>
<Select
label={t("item.create.destination.label")}
description={t("item.create.destination.description")}
data={placementOptions}
value={targetSectionId}
onChange={setTargetSectionId}
allowDeselect={false}
/>
</Box>
)}
{filteredItems.map((item) => (
<WidgetItem
key={item.kind}
item={item}
onSelect={() => void handleAdd(item.kind)}
onIntent={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
<Flex gap="md" align="stretch">
<Box style={{ flex: "1 1 0%", minWidth: 0 }}>
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("item.create.search")}...`}
ariaLabel={t("item.create.search")}
onSearchKeyDown={(event) => {
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
const [item] = filteredItems;
if (item) void handleAdd(item.kind);
}
}}
disabled={loadingSelection !== null}
loading={loadingSelection === item.kind}
connectionStatus={getWidgetConnectionStatus({
supportedIntegrations: item.supportedIntegrations,
availableKinds,
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
})}
/>
))}
{isAdmin && (
<>
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<CatalogItem
key={def.id}
height={selectGridCardHeight}
label={def.name}
status={t("item.create.connectionStatus.noConnectionRequired")}
>
{placementOptions.length > 1 && (
<Box style={{ gridColumn: "1 / -1" }}>
<Select
label={t("item.create.destination.label")}
description={t("item.create.destination.description")}
data={placementOptions}
value={targetSectionId}
onChange={setTargetSectionId}
allowDeselect={false}
/>
</Box>
)}
{filteredItems.map((item) => (
<WidgetItem
key={item.kind}
item={item}
onSelect={() => void handleAdd(item.kind)}
onFocus={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
setPreviewKind(item.kind);
}}
onPointerEnter={() => {
void loadWidgetDefinition(item.kind).catch(() => undefined);
preloadWidgetEditModal();
setPreviewKind(item.kind);
}}
disabled={loadingSelection !== null}
busy={loadingSelection === `custom:${def.id}`}
onSelect={() => void handleAddCustomWidget(def)}
onFocus={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{def.name}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
<ConnectionStatusBadge status="noConnectionRequired" />
</Stack>
</CatalogItem>
loading={loadingSelection === item.kind}
connectionStatus={getWidgetConnectionStatus({
supportedIntegrations: item.supportedIntegrations,
availableKinds,
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
})}
/>
))}
<Box style={{ gridColumn: "1 / -1" }}>
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
</Box>
</>
)}
{isAdmin && (
<>
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<CatalogItem
key={def.id}
height={selectGridCardHeight}
label={def.name}
status={t("item.create.connectionStatus.noConnectionRequired")}
disabled={loadingSelection !== null}
busy={loadingSelection === `custom:${def.id}`}
onSelect={() => void handleAddCustomWidget(def)}
onFocus={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{def.name}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? ""}
</Text>
<ConnectionStatusBadge status="noConnectionRequired" />
</Stack>
</CatalogItem>
))}
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
<Box style={{ gridColumn: "1 / -1" }}>
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
</Box>
</>
)}
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
</Box>
{previewKind && (
<Box w={320} style={{ flexShrink: 0 }}>
<WidgetPreviewPanel kind={previewKind} integrationIds={previewIntegrationIds} />
</Box>
)}
</SelectGridLayout>
</Flex>
);
};
const ItemSelectModalFrame = ({
@@ -509,7 +549,8 @@ export const ItemSelectModal = createModal<{
const WidgetItem = ({
item,
onSelect,
onIntent,
onFocus,
onPointerEnter,
disabled,
loading,
connectionStatus,
@@ -522,7 +563,8 @@ const WidgetItem = ({
icon: TablerIcon;
};
onSelect: () => void;
onIntent: () => void;
onFocus: () => void;
onPointerEnter: () => void;
disabled: boolean;
loading: boolean;
connectionStatus: WidgetConnectionStatus;
@@ -535,8 +577,8 @@ const WidgetItem = ({
height={selectGridCardHeight}
label={item.name}
status={statusLabel}
onFocus={onIntent}
onPointerEnter={onIntent}
onFocus={onFocus}
onPointerEnter={onPointerEnter}
onSelect={onSelect}
disabled={disabled}
busy={loading}
@@ -0,0 +1,96 @@
"use client";
import { Suspense, use } from "react";
import { Center, Loader, Paper, Stack, Text } from "@mantine/core";
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 PREVIEW_CELL_SIZE = 56;
const PREVIEW_MIN_HEIGHT = 120;
const PREVIEW_MAX_HEIGHT = 260;
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)),
};
};
interface WidgetPreviewProps {
kind: WidgetKind;
integrationIds: string[];
}
export const WidgetPreviewPanel = ({ kind, integrationIds }: WidgetPreviewProps) => {
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>
<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>
</Paper>
);
};
const WidgetPreviewContent = ({
kind,
integrationIds,
width,
height,
}: {
kind: WidgetKind;
integrationIds: string[];
width: number;
height: number;
}) => {
const settings = useSettings();
const { definition, Component } = use(loadWidgetResources(kind));
const options = reduceWidgetOptionsWithDefinition(definition, settings, {});
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={options as never}
integrationIds={integrationIds}
width={width}
height={height}
isEditMode={false}
boardId={undefined}
itemId={undefined}
setOptions={() => undefined}
/>
</Stack>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
};
@@ -26,7 +26,9 @@ export const useBoardAddActions = () => {
const addApp = useCallback(
() =>
openAppSelectModal({
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
onSelectMany: (apps) => {
apps.forEach((app) => createItem({ kind: "app", options: { appId: app.id } }));
},
withCreate: canCreateApp,
}),
[canCreateApp, createItem, openAppSelectModal],