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:
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,
|
||||
});
|
||||
Reference in new issue
Block a user