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],
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { Button, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { IconCheck, IconPlus, IconApps } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -10,16 +10,21 @@ import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui"
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
type SelectableApp = RouterOutputs["app"]["selectable"][number];
interface AppSelectModalProps {
onSelect?: (app: RouterOutputs["app"]["selectable"][number]) => void;
onSelect?: (app: SelectableApp) => void;
onSelectMany?: (apps: SelectableApp[]) => void;
withCreate: boolean;
}
export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, innerProps }) => {
const [search, setSearch] = useState("");
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
const t = useI18n();
const { data: apps = [], isPending } = clientApi.app.selectable.useQuery();
const { openModal: openQuickAddAppModal } = useModalAction(QuickAddAppModal);
const multiSelect = Boolean(innerProps.onSelectMany);
const filteredApps = useMemo(
() =>
@@ -29,14 +34,45 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
[apps, search],
);
const handleSelect = (app: RouterOutputs["app"]["selectable"][number]) => {
const selectedApps = useMemo(
() => filteredApps.filter((app) => selectedAppIds.has(app.id)),
[filteredApps, selectedAppIds],
);
const handleSelect = (app: SelectableApp) => {
if (multiSelect) {
setSelectedAppIds((current) => {
const next = new Set(current);
if (next.has(app.id)) next.delete(app.id);
else next.add(app.id);
return next;
});
return;
}
innerProps.onSelect?.(app);
actions.closeModal();
};
const handleSubmit = () => {
if (multiSelect) {
innerProps.onSelectMany?.(selectedApps);
actions.closeModal();
return;
}
const [first] = filteredApps;
if (first) {
innerProps.onSelect?.(first);
actions.closeModal();
}
};
const handleAddNewApp = () => {
openQuickAddAppModal({
onClose(app) {
if (multiSelect) {
setSelectedAppIds((current) => new Set(current).add(app.id));
return;
}
innerProps.onSelect?.(app);
actions.closeModal();
},
@@ -44,74 +80,97 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
};
return (
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("app.action.select.search")}...`}
ariaLabel={t("app.action.select.search")}
onSearchKeyDown={(event) => {
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
handleSelect(filteredApps[0]);
}
}}
>
{innerProps.withCreate && (
<CatalogItem
height={selectGridCardHeight}
label={t("app.action.create.title")}
status={t("app.action.create.action")}
onSelect={handleAddNewApp}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<IconPlus size={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{t("app.action.create.title")}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{t("app.action.create.description")}
</Text>
<Text size="xs" c="blue" fw={500} mt="auto">
{t("app.action.create.action")}
</Text>
</Stack>
</CatalogItem>
)}
{filteredApps.map((app) => (
<CatalogItem
key={app.id}
height={selectGridCardHeight}
label={app.name}
status={t("app.action.select.action", { app: app.name })}
onSelect={() => handleSelect(app)}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{app.name}
</Text>
</Group>
<Tooltip label={app.description} multiline w={250} disabled={!app.description}>
<>
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("app.action.select.search")}...`}
ariaLabel={t("app.action.select.search")}
onSearchKeyDown={(event) => {
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
handleSelect(filteredApps[0]);
}
}}
>
{innerProps.withCreate && (
<CatalogItem
height={selectGridCardHeight}
label={t("app.action.create.title")}
status={t("app.action.create.action")}
onSelect={handleAddNewApp}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<IconPlus size={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{t("app.action.create.title")}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{app.description ?? ""}
{t("app.action.create.description")}
</Text>
</Tooltip>
<Text size="xs" c="blue" fw={500} mt="auto">
{t("app.action.select.action", { app: app.name })}
</Text>
</Stack>
</CatalogItem>
))}
<Text size="xs" c="blue" fw={500} mt="auto">
{t("app.action.create.action")}
</Text>
</Stack>
</CatalogItem>
)}
{filteredApps.length === 0 && !isPending && (
<Center p="xl">
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
</Center>
{filteredApps.map((app) => (
<CatalogItem
key={app.id}
height={selectGridCardHeight}
label={app.name}
status={
multiSelect
? selectedAppIds.has(app.id)
? t("app.action.select.selected")
: t("app.action.select.toggle")
: t("app.action.select.action", { app: app.name })
}
selected={multiSelect ? selectedAppIds.has(app.id) : undefined}
onSelect={() => handleSelect(app)}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{app.name}
</Text>
{multiSelect && selectedAppIds.has(app.id) && (
<IconCheck size={18} color="var(--mantine-primary-color-filled)" />
)}
</Group>
<Tooltip label={app.description} multiline w={250} disabled={!app.description}>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{app.description ?? ""}
</Text>
</Tooltip>
<Text size="xs" c="blue" fw={500} mt="auto">
{multiSelect
? selectedAppIds.has(app.id)
? t("app.action.select.selected")
: t("app.action.select.toggle")
: t("app.action.select.action", { app: app.name })}
</Text>
</Stack>
</CatalogItem>
))}
{filteredApps.length === 0 && !isPending && (
<Center p="xl">
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
{multiSelect && (
<Group justify="flex-end" mt="md">
<Button leftSection={<IconApps size={16} />} disabled={selectedAppIds.size === 0} onClick={handleSubmit}>
{t("app.action.select.multiple", { count: String(selectedAppIds.size) })}
</Button>
</Group>
)}
</SelectGridLayout>
</>
);
}).withOptions({
defaultTitle: (t) => t("app.action.select.title"),
+7 -1
View File
@@ -847,7 +847,10 @@
"search": "Search for an app",
"noResults": "No results",
"action": "Select {app}",
"title": "Select an app to add to this board"
"title": "Select an app to add to this board",
"toggle": "Click to select",
"selected": "Selected",
"multiple": "Add {count}"
},
"create": {
"title": "Create new app",
@@ -2746,6 +2749,9 @@
"title": "Unable to add item",
"message": "The item could not be added to this board"
}
},
"preview": {
"label": "Live preview"
}
},
"moveResize": {