diff --git a/apps/nextjs/src/components/board/board-setup-checklist.module.css b/apps/nextjs/src/components/board/board-setup-checklist.module.css
index 877814158..5ca1a0159 100644
--- a/apps/nextjs/src/components/board/board-setup-checklist.module.css
+++ b/apps/nextjs/src/components/board/board-setup-checklist.module.css
@@ -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 {
diff --git a/apps/nextjs/src/components/board/board-setup-checklist.tsx b/apps/nextjs/src/components/board/board-setup-checklist.tsx
index b702e5186..16b0de6b9 100644
--- a/apps/nextjs/src/components/board/board-setup-checklist.tsx
+++ b/apps/nextjs/src/components/board/board-setup-checklist.tsx
@@ -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 (
-
+
}
@@ -45,12 +46,12 @@ export const BoardSetupChecklist = () => {
>
{t("resume", { completed: progress.completedCount, total: progress.totalCount })}
-
+
);
}
return (
-
+
@@ -93,7 +94,7 @@ export const BoardSetupChecklist = () => {
/>
-
+
);
};
diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx
index 40b8ed6c2..80ff4b318 100644
--- a/apps/nextjs/src/components/board/items/item-select-modal.tsx
+++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx
@@ -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(placementOptions[0]?.value ?? null);
const [loadingSelection, setLoadingSelection] = useState(null);
+ const [previewKind, setPreviewKind] = useState(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 (
- {
- if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
- const [item] = filteredItems;
- if (item) void handleAdd(item.kind);
- }
- }}
- >
- {placementOptions.length > 1 && (
-
-
-
- )}
- {filteredItems.map((item) => (
- void handleAdd(item.kind)}
- onIntent={() => {
- void loadWidgetDefinition(item.kind).catch(() => undefined);
- preloadWidgetEditModal();
+
+
+ {
+ 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 && (
- <>
-
- {filteredCustomWidgets.map((def) => (
-
+ {placementOptions.length > 1 && (
+
+
+
+ )}
+ {filteredItems.map((item) => (
+ 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)}
- >
-
-
- {def.iconUrl ? (
-
- ) : (
-
- )}
-
- {def.name}
-
-
-
- {def.description ?? ""}
-
-
-
-
+ loading={loadingSelection === item.kind}
+ connectionStatus={getWidgetConnectionStatus({
+ supportedIntegrations: item.supportedIntegrations,
+ availableKinds,
+ connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
+ })}
+ />
))}
-
- {t("workshop.installDialog")}
-
- >
- )}
+ {isAdmin && (
+ <>
+
+ {filteredCustomWidgets.map((def) => (
+ void handleAddCustomWidget(def)}
+ onFocus={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
+ onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
+ >
+
+
+ {def.iconUrl ? (
+
+ ) : (
+
+ )}
+
+ {def.name}
+
+
+
+ {def.description ?? ""}
+
+
+
+
+ ))}
- {filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
-
- {t("common.noResults")}
-
+
+ {t("workshop.installDialog")}
+
+ >
+ )}
+
+ {filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
+
+ {t("common.noResults")}
+
+ )}
+
+
+ {previewKind && (
+
+
+
)}
-
+
);
};
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}
diff --git a/apps/nextjs/src/components/board/items/widget-preview-panel.tsx b/apps/nextjs/src/components/board/items/widget-preview-panel.tsx
new file mode 100644
index 000000000..2441e71ce
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/widget-preview-panel.tsx
@@ -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 (
+
+
+ {t("item.create.preview.label")}
+
+
+
+
+
+ }
+ >
+
+
+
+
+ );
+};
+
+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 (
+
+ {({ reset }) => (
+ (
+
+ )}
+ >
+
+ undefined}
+ />
+
+
+ )}
+
+ );
+};
diff --git a/apps/nextjs/src/components/board/use-board-add-actions.ts b/apps/nextjs/src/components/board/use-board-add-actions.ts
index 7718807fb..a448f3cab 100644
--- a/apps/nextjs/src/components/board/use-board-add-actions.ts
+++ b/apps/nextjs/src/components/board/use-board-add-actions.ts
@@ -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],
diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx
index 7a468f359..157039f82 100644
--- a/packages/modals-collection/src/apps/app-select-modal.tsx
+++ b/packages/modals-collection/src/apps/app-select-modal.tsx
@@ -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(({ actions, innerProps }) => {
const [search, setSearch] = useState("");
+ const [selectedAppIds, setSelectedAppIds] = useState>(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(({ 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(({ actions, inner
};
return (
- {
- if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
- handleSelect(filteredApps[0]);
- }
- }}
- >
- {innerProps.withCreate && (
-
-
-
-
-
- {t("app.action.create.title")}
-
-
-
- {t("app.action.create.description")}
-
-
- {t("app.action.create.action")}
-
-
-
- )}
-
- {filteredApps.map((app) => (
- handleSelect(app)}
- >
-
-
-
-
- {app.name}
-
-
-
+ <>
+ {
+ if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
+ handleSelect(filteredApps[0]);
+ }
+ }}
+ >
+ {innerProps.withCreate && (
+
+
+
+
+
+ {t("app.action.create.title")}
+
+
- {app.description ?? ""}
+ {t("app.action.create.description")}
-
-
- {t("app.action.select.action", { app: app.name })}
-
-
-
- ))}
+
+ {t("app.action.create.action")}
+
+
+
+ )}
- {filteredApps.length === 0 && !isPending && (
-
- {t("app.action.select.noResults")}
-
+ {filteredApps.map((app) => (
+ handleSelect(app)}
+ >
+
+
+
+
+ {app.name}
+
+ {multiSelect && selectedAppIds.has(app.id) && (
+
+ )}
+
+
+
+ {app.description ?? ""}
+
+
+
+ {multiSelect
+ ? selectedAppIds.has(app.id)
+ ? t("app.action.select.selected")
+ : t("app.action.select.toggle")
+ : t("app.action.select.action", { app: app.name })}
+
+
+
+ ))}
+
+ {filteredApps.length === 0 && !isPending && (
+
+ {t("app.action.select.noResults")}
+
+ )}
+
+ {multiSelect && (
+
+ } disabled={selectedAppIds.size === 0} onClick={handleSubmit}>
+ {t("app.action.select.multiple", { count: String(selectedAppIds.size) })}
+
+
)}
-
+ >
);
}).withOptions({
defaultTitle: (t) => t("app.action.select.title"),
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index cea6d78b6..7fc9f9e51 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -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": {