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:
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],
|
||||
|
||||
Reference in new issue
Block a user