import { useMemo, useRef, useState } from "react"; import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core"; import { IconApi } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { createId } from "@homarr/common"; import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { showErrorNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { useItemActions } from "./item-actions"; import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; import classes from "./item-select-modal.module.css"; interface ItemSelectModalContentProps { actions: { closeModal: () => void }; innerProps: void; integrationData: RouterOutputs["integration"]["all"] | undefined; customWidgetDefs: RouterOutputs["customWidget"]["all"] | undefined; ensureIntegrationDataAsync: () => Promise; } const ItemSelectModalContent = ({ actions, integrationData, customWidgetDefs, ensureIntegrationDataAsync, }: ItemSelectModalContentProps) => { const [search, setSearch] = useState(""); const [loadingSelection, setLoadingSelection] = useState(null); const selectionLock = useRef(false); const t = useI18n(); const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal); const settings = useSettings(); const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]); const items = useMemo( () => widgetKinds .filter((kind) => kind !== "customApi") .map((kind) => { return { kind, supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter( (integration) => integration !== "mock", ), icon: widgetCatalogIcons[kind], name: t(`widget.${kind}.name`), description: t(`widget.${kind}.description`), }; }) .sort((itemA, itemB) => { if (itemA.kind === "app") return -1; if (itemB.kind === "app") return 1; return itemA.name.localeCompare(itemB.name); }), [t], ); const filteredItems = useMemo(() => { const query = search.toLowerCase().trim(); if (!query) return items; return items.filter( (item) => item.name.toLowerCase().includes(query) || item.supportedIntegrations.some((kind) => getIntegrationName(kind).toLowerCase().includes(query)), ); }, [items, search]); const filteredCustomWidgets = useMemo( () => (customWidgetDefs ?? []).filter((def) => def.enabled && def.name.toLowerCase().includes(search.toLowerCase())), [customWidgetDefs, search], ); const notifyDefinitionLoadError = (error: unknown) => { showErrorNotification({ title: t("common.error"), message: error instanceof Error ? error.message : String(error), }); }; const handleAddCustomWidget = async (definitionId: string) => { if (!tryLockSelection(selectionLock)) return; setLoadingSelection(`custom:${definitionId}`); try { const definition = await loadWidgetDefinition("customApi"); const itemId = createId(); const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); createItem({ id: itemId, kind: "customApi", integrationIds: [] }); updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } }); actions.closeModal(); } catch (error) { notifyDefinitionLoadError(error); } finally { unlockSelection(selectionLock); setLoadingSelection(null); } }; const handleAdd = async (kind: WidgetKind) => { if (!tryLockSelection(selectionLock)) return; setLoadingSelection(kind); preloadWidgetEditModal(); try { const definition = await loadWidgetDefinition(kind); const hasIntegrationSupport = "supportedIntegrations" in definition; const matchingIntegrations = await resolveMatchingIntegrationsAsync({ hasIntegrationSupport, supportedIntegrations: definition.supportedIntegrations ?? [], currentData: integrationData, ensureDataAsync: ensureIntegrationDataAsync, }); const integrationIds = matchingIntegrations.map((i) => i.id); const itemId = createId(); const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings); createItem({ id: itemId, kind, integrationIds }); actions.closeModal(); openEditModal( { kind, definition, value: { advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, options: defaultOptions, integrationIds, }, onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { updateItemOptions({ itemId, newOptions: options }); updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds }); }, integrationData: matchingIntegrations, integrationSupport: hasIntegrationSupport, settings, }, { title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, }, ); } catch (error) { notifyDefinitionLoadError(error); } finally { unlockSelection(selectionLock); setLoadingSelection(null); } }; return ( { if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) { const [item] = filteredItems; if (item) void handleAdd(item.kind); } }} > {filteredItems.map((item) => ( void handleAdd(item.kind)} onIntent={() => { void loadWidgetDefinition(item.kind).catch(() => undefined); preloadWidgetEditModal(); }} disabled={loadingSelection !== null} loading={loadingSelection === item.kind} hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))} /> ))} {filteredCustomWidgets.length > 0 && ( <> {filteredCustomWidgets.map((def) => ( void loadWidgetDefinition("customApi").catch(() => undefined)} > {def.iconUrl ? ( ) : ( )} {def.name} {def.description ?? def.url} ))} )} {filteredItems.length === 0 && filteredCustomWidgets.length === 0 && (
{t("common.noResults")}
)}
); }; const ItemSelectModalFrame = (props: Pick) => { const utils = clientApi.useUtils(); const { data: integrationData } = clientApi.integration.all.useQuery(); const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery(); return ( utils.integration.all.ensureData()} /> ); }; export const ItemSelectModal = createModal((props) => ).withOptions({ defaultTitle: (t) => t("item.create.title"), size: modalSizeSelect, }); const WidgetItem = ({ item, onSelect, onIntent, disabled, loading, hasMatchingIntegration, }: { item: { kind: WidgetKind; supportedIntegrations: IntegrationKind[]; name: string; description: string; icon: TablerIcon; }; onSelect: () => void; onIntent: () => void; disabled: boolean; loading: boolean; hasMatchingIntegration: boolean; }) => { const t = useI18n(); return ( {item.name} {item.description} ); }; const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => { if (integrations.length === 0) { return null; } const countToShow = integrations.length >= 8 ? 6 : 7; const moreCount = integrations.length - countToShow; return ( {integrations.slice(0, countToShow).map((integration) => ( ))} {moreCount > 0 && ( {integrations.slice(countToShow).map((integration) => (
{getIntegrationName(integration)}
))} } > +{moreCount}
)}
); };