From e3fb8572cd0b35fb5ba0d192fb53800e876967b3 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 1 May 2026 09:58:47 +0200 Subject: [PATCH] feat(ui): add reusable integration select grid and modal components Create IntegrationSelectGrid (single-select) and IntegrationMultiSelectGrid (multi-select) components with search, category badges, widget count tooltips, and featured integration sorting. Use SimpleGrid to prevent horizontal overflow. Add stepper modal for creating integrations from the dashboard. --- .../integration-multi-select-grid.tsx | 201 ++++++++++++++++++ .../integration/integration-picker-page.tsx | 25 +++ .../integration/integration-select-grid.tsx | 171 +++++++++++++++ .../integration/integration-select-modal.tsx | 56 +++++ 4 files changed, 453 insertions(+) create mode 100644 apps/nextjs/src/components/integration/integration-multi-select-grid.tsx create mode 100644 apps/nextjs/src/components/integration/integration-picker-page.tsx create mode 100644 apps/nextjs/src/components/integration/integration-select-grid.tsx create mode 100644 apps/nextjs/src/components/integration/integration-select-modal.tsx diff --git a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx new file mode 100644 index 000000000..e253f8468 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Badge, Card, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; +import { IconCheck, IconPuzzle, IconSearch } from "@tabler/icons-react"; + +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { featuredIntegrations, getIntegrationName, integrationDefs, integrationKinds } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { IntegrationAvatar } from "@homarr/ui"; +import { widgetImports } from "@homarr/widgets"; + +interface IntegrationMultiSelectGridProps { + onSelectionChange: (kinds: IntegrationKind[]) => void; + enableMockIntegration?: boolean; +} + +const getWidgetsByIntegration = () => { + const map: Record = Object.fromEntries( + integrationKinds.map((kind) => [kind, []]), + ) as Record; + + for (const [widgetKind, widget] of Object.entries(widgetImports) as [ + WidgetKind, + (typeof widgetImports)[WidgetKind], + ][]) { + const supported = + "supportedIntegrations" in widget.definition ? (widget.definition.supportedIntegrations as string[]) : []; + for (const kind of supported) { + if (kind in map) { + map[kind as IntegrationKind].push(widgetKind); + } + } + } + return map; +}; + +const widgetsByIntegration = getWidgetsByIntegration(); + +const categoryLabels: Record = { + dnsHole: "DNS", + mediaService: "Media", + calendar: "Calendar", + mediaSearch: "Search", + mediaRelease: "Releases", + mediaRequest: "Requests", + downloadClient: "Downloads", + usenet: "Usenet", + torrent: "Torrent", + miscellaneous: "Misc", + smartHomeServer: "Smart Home", + indexerManager: "Indexer", + healthMonitoring: "Health", + search: "Search", + mediaTranscoding: "Transcoding", + networkController: "Network", + releasesProvider: "Releases", + notifications: "Notifications", + firewall: "Firewall", + timetable: "Timetable", + photoService: "Photos", + notes: "Notes", + mediaMonitoring: "Monitoring", + speedtest: "Speed Test", +}; + +const CARD_HEIGHT = 180; + +export const IntegrationMultiSelectGrid = ({ + onSelectionChange, + enableMockIntegration = false, +}: IntegrationMultiSelectGridProps) => { + const [search, setSearch] = useState(""); + const [selectedKinds, setSelectedKinds] = useState>(new Set()); + const t = useI18n(); + + useEffect(() => { + onSelectionChange(Array.from(selectedKinds)); + }, [selectedKinds, onSelectionChange]); + + const toggleKind = useCallback((kind: IntegrationKind) => { + setSelectedKinds((prev) => { + const next = new Set(prev); + if (next.has(kind)) { + next.delete(kind); + } else { + next.add(kind); + } + return next; + }); + }, []); + + const integrations = useMemo(() => { + return integrationKinds + .filter((kind) => enableMockIntegration || kind !== "mock") + .map((kind) => ({ + kind, + name: getIntegrationName(kind), + categories: [...new Set(integrationDefs[kind].category.flat())] as string[], + widgets: widgetsByIntegration[kind], + })) + .sort((left, right) => { + const leftIdx = featuredIntegrations.indexOf(left.kind); + const rightIdx = featuredIntegrations.indexOf(right.kind); + if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; + if (leftIdx !== -1) return -1; + if (rightIdx !== -1) return 1; + return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); + }); + }, [enableMockIntegration]); + + const filtered = useMemo( + () => integrations.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())), + [integrations, search], + ); + + return ( + + setSearch(event.currentTarget.value)} + leftSection={} + placeholder={`${t("integration.page.list.search")}...`} + data-autofocus + /> + + + + {filtered.map((integration) => { + const isSelected = selectedKinds.has(integration.kind); + return ( + toggleKind(integration.kind)} + withBorder + > + + + + {integration.name} + + {isSelected && } + + +
+ +
+ + + + {integration.categories.slice(0, 2).map((cat) => ( + + {categoryLabels[cat] ?? cat} + + ))} + + {integration.widgets.length > 0 ? ( + + {integration.widgets.map((widgetKind) => ( + + {t(`widget.${widgetKind}.name`)} + + ))} + + } + > + }> + {integration.widgets.length} {integration.widgets.length === 1 ? "widget" : "widgets"} + + + ) : ( + + No widgets + + )} +
+
+ + ); + })} + + {filtered.length === 0 && ( +
+ {t("common.noResults")} +
+ )} + + + + ); +}; diff --git a/apps/nextjs/src/components/integration/integration-picker-page.tsx b/apps/nextjs/src/components/integration/integration-picker-page.tsx new file mode 100644 index 000000000..cd4618ef7 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-picker-page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; + +import type { IntegrationKind } from "@homarr/definitions"; + +import { IntegrationSelectGrid } from "./integration-select-grid"; + +interface IntegrationPickerPageProps { + enableMockIntegration: boolean; +} + +export const IntegrationPickerPage = ({ enableMockIntegration }: IntegrationPickerPageProps) => { + const router = useRouter(); + + const handleSelect = useCallback( + (kind: IntegrationKind) => { + router.push(`/manage/integrations/new?kind=${kind}`); + }, + [router], + ); + + return ; +}; diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx new file mode 100644 index 000000000..321473d77 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Badge, Card, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; +import { IconPuzzle, IconSearch } from "@tabler/icons-react"; + +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { featuredIntegrations, getIntegrationName, integrationDefs, integrationKinds } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { IntegrationAvatar } from "@homarr/ui"; +import { widgetImports } from "@homarr/widgets"; + +interface IntegrationSelectGridProps { + onSelect: (kind: IntegrationKind) => void; + enableMockIntegration?: boolean; +} + +const getWidgetsByIntegration = () => { + const map: Record = Object.fromEntries( + integrationKinds.map((kind) => [kind, []]), + ) as Record; + + for (const [widgetKind, widget] of Object.entries(widgetImports) as [ + WidgetKind, + (typeof widgetImports)[WidgetKind], + ][]) { + const supported = + "supportedIntegrations" in widget.definition ? (widget.definition.supportedIntegrations as string[]) : []; + for (const kind of supported) { + if (kind in map) { + map[kind as IntegrationKind].push(widgetKind); + } + } + } + return map; +}; + +const widgetsByIntegration = getWidgetsByIntegration(); + +const categoryLabels: Record = { + dnsHole: "DNS", + mediaService: "Media", + calendar: "Calendar", + mediaSearch: "Search", + mediaRelease: "Releases", + mediaRequest: "Requests", + downloadClient: "Downloads", + usenet: "Usenet", + torrent: "Torrent", + miscellaneous: "Misc", + smartHomeServer: "Smart Home", + indexerManager: "Indexer", + healthMonitoring: "Health", + search: "Search", + mediaTranscoding: "Transcoding", + networkController: "Network", + releasesProvider: "Releases", + notifications: "Notifications", + firewall: "Firewall", + timetable: "Timetable", + photoService: "Photos", + notes: "Notes", + mediaMonitoring: "Monitoring", + speedtest: "Speed Test", +}; + +const CARD_HEIGHT = 180; + +export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => { + const [search, setSearch] = useState(""); + const t = useI18n(); + + const integrations = useMemo(() => { + return integrationKinds + .filter((kind) => enableMockIntegration || kind !== "mock") + .map((kind) => ({ + kind, + name: getIntegrationName(kind), + categories: [...new Set(integrationDefs[kind].category.flat())] as string[], + widgets: widgetsByIntegration[kind], + })) + .sort((left, right) => { + const leftIdx = featuredIntegrations.indexOf(left.kind); + const rightIdx = featuredIntegrations.indexOf(right.kind); + if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; + if (leftIdx !== -1) return -1; + if (rightIdx !== -1) return 1; + return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); + }); + }, [enableMockIntegration]); + + const filtered = useMemo( + () => integrations.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())), + [integrations, search], + ); + + return ( + + setSearch(event.currentTarget.value)} + leftSection={} + placeholder={`${t("integration.page.list.search")}...`} + data-autofocus + /> + + + + {filtered.map((integration) => ( + onSelect(integration.kind)} + withBorder + > + + + {integration.name} + + +
+ +
+ + + + {integration.categories.slice(0, 2).map((cat) => ( + + {categoryLabels[cat] ?? cat} + + ))} + + {integration.widgets.length > 0 ? ( + + {integration.widgets.map((widgetKind) => ( + + {t(`widget.${widgetKind}.name`)} + + ))} + + } + > + }> + {integration.widgets.length} {integration.widgets.length === 1 ? "widget" : "widgets"} + + + ) : ( + + No widgets + + )} +
+
+ + ))} + + {filtered.length === 0 && ( +
+ {t("common.noResults")} +
+ )} + + + + ); +}; diff --git a/apps/nextjs/src/components/integration/integration-select-modal.tsx b/apps/nextjs/src/components/integration/integration-select-modal.tsx new file mode 100644 index 000000000..c93f1d5de --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-select-modal.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useState } from "react"; +import { Group, ScrollArea, Title } from "@mantine/core"; +import { IconArrowLeft } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { getIntegrationName } from "@homarr/definitions"; +import { createModal } from "@homarr/modals"; +import { IntegrationAvatar } from "@homarr/ui"; + +import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; +import { IntegrationSelectGrid } from "./integration-select-grid"; + +interface IntegrationSelectModalProps { + onSuccess?: () => void; + enableMockIntegration?: boolean; +} + +export const IntegrationSelectModal = createModal(({ actions, innerProps }) => { + const [step, setStep] = useState<"select" | "form">("select"); + const [selectedKind, setSelectedKind] = useState(null); + + const handleSelect = (kind: IntegrationKind) => { + setSelectedKind(kind); + setStep("form"); + }; + + const handleBack = () => { + setStep("select"); + setSelectedKind(null); + }; + + const handleSuccess = () => { + innerProps.onSuccess?.(); + actions.closeModal(); + }; + + if (step === "form" && selectedKind) { + return ( + + + + + {getIntegrationName(selectedKind)} + + + + ); + } + + return ; +}).withOptions({ + defaultTitle: (t) => t("integration.action.create"), + size: 1200, +});