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.
This commit is contained in:
Thomas Camlong committed 2026-05-01 09:58:47 +02:00
1 parent 9e3b4955b9
commit e3fb8572cd
4 files changed
+453

No files matched your search

@@ -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<IntegrationKind, WidgetKind[]> = Object.fromEntries(
integrationKinds.map((kind) => [kind, []]),
) as Record<IntegrationKind, WidgetKind[]>;
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<string, string> = {
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<Set<IntegrationKind>>(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 (
<Stack>
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch />}
placeholder={`${t("integration.page.list.search")}...`}
data-autofocus
/>
<ScrollArea.Autosize mah="60vh">
<SimpleGrid cols={{ base: 2, xs: 3, sm: 4, md: 5 }} spacing="sm">
{filtered.map((integration) => {
const isSelected = selectedKinds.has(integration.kind);
return (
<Card
key={integration.kind}
h={CARD_HEIGHT}
style={{
cursor: "pointer",
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: isSelected ? 2 : undefined,
}}
onClick={() => toggleKind(integration.kind)}
withBorder
>
<Stack justify="space-between" h="100%" gap="xs">
<Group justify="space-between" wrap="nowrap">
<Text fw={500} lh={1.2} size="sm" lineClamp={1} style={{ flex: 1 }}>
{integration.name}
</Text>
{isSelected && <IconCheck size={18} color="var(--mantine-color-blue-6)" />}
</Group>
<Center>
<IntegrationAvatar kind={integration.kind} size="lg" />
</Center>
<Stack gap={4} align="center">
<Group gap={4} justify="center" wrap="wrap">
{integration.categories.slice(0, 2).map((cat) => (
<Badge key={cat} variant="light" size="xs">
{categoryLabels[cat] ?? cat}
</Badge>
))}
</Group>
{integration.widgets.length > 0 ? (
<Tooltip
multiline
w={200}
label={
<Stack gap={4}>
{integration.widgets.map((widgetKind) => (
<Text key={widgetKind} size="xs">
{t(`widget.${widgetKind}.name`)}
</Text>
))}
</Stack>
}
>
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
{integration.widgets.length} {integration.widgets.length === 1 ? "widget" : "widgets"}
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" size="sm">
No widgets
</Badge>
)}
</Stack>
</Stack>
</Card>
);
})}
{filtered.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SimpleGrid>
</ScrollArea.Autosize>
</Stack>
);
};
@@ -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 <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={enableMockIntegration} />;
};
@@ -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<IntegrationKind, WidgetKind[]> = Object.fromEntries(
integrationKinds.map((kind) => [kind, []]),
) as Record<IntegrationKind, WidgetKind[]>;
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<string, string> = {
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 (
<Stack>
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch />}
placeholder={`${t("integration.page.list.search")}...`}
data-autofocus
/>
<ScrollArea.Autosize mah="70vh">
<SimpleGrid cols={{ base: 2, xs: 3, sm: 4, md: 5 }} spacing="sm">
{filtered.map((integration) => (
<Card
key={integration.kind}
h={CARD_HEIGHT}
style={{ cursor: "pointer" }}
onClick={() => onSelect(integration.kind)}
withBorder
>
<Stack justify="space-between" h="100%" gap="xs">
<Text fw={500} ta="center" lh={1.2} size="sm" lineClamp={1}>
{integration.name}
</Text>
<Center>
<IntegrationAvatar kind={integration.kind} size="lg" />
</Center>
<Stack gap={4} align="center">
<Group gap={4} justify="center" wrap="wrap">
{integration.categories.slice(0, 2).map((cat) => (
<Badge key={cat} variant="light" size="xs">
{categoryLabels[cat] ?? cat}
</Badge>
))}
</Group>
{integration.widgets.length > 0 ? (
<Tooltip
multiline
w={200}
label={
<Stack gap={4}>
{integration.widgets.map((widgetKind) => (
<Text key={widgetKind} size="xs">
{t(`widget.${widgetKind}.name`)}
</Text>
))}
</Stack>
}
>
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
{integration.widgets.length} {integration.widgets.length === 1 ? "widget" : "widgets"}
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" size="sm">
No widgets
</Badge>
)}
</Stack>
</Stack>
</Card>
))}
{filtered.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SimpleGrid>
</ScrollArea.Autosize>
</Stack>
);
};
@@ -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<IntegrationSelectModalProps>(({ actions, innerProps }) => {
const [step, setStep] = useState<"select" | "form">("select");
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(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 (
<ScrollArea.Autosize mah="80vh">
<Group gap="xs" mb="md" style={{ cursor: "pointer" }} onClick={handleBack}>
<IconArrowLeft size={18} />
<IntegrationAvatar kind={selectedKind} size="sm" />
<Title order={4}>{getIntegrationName(selectedKind)}</Title>
</Group>
<NewIntegrationForm kind={selectedKind} onSuccess={handleSuccess} onCancel={handleBack} />
</ScrollArea.Autosize>
);
}
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={innerProps.enableMockIntegration} />;
}).withOptions({
defaultTitle: (t) => t("integration.action.create"),
size: 1200,
});