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 ( -
+ -
+ ); } 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)} + 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} - - {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} + + {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 && ( + + + )} - + ); }).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": {