Files
homarr/packages/ui/src/integration-select-grid/integration-select-grid.tsx
T

238 lines
6.8 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { Badge, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import {
IconAd,
IconApps,
IconBinaryTree,
IconBookmark,
IconBrandDocker,
IconBrandMinecraft,
IconBrowser,
IconBuildingBank,
IconBusStop,
IconCalendar,
IconChartBar,
IconClock,
IconClockPlay,
IconCloud,
IconDeviceCctv,
IconDeviceGamepad,
IconDownload,
IconGraphFilled,
IconHeartRateMonitor,
IconHourglass,
IconMessage,
IconMovie,
IconNotes,
IconPuzzle,
IconReportSearch,
IconRobot,
IconRocket,
IconRss,
IconSearch,
IconServer2,
IconTicket,
IconTopologyFull,
IconTransform,
IconVideo,
IconWall,
IconWind,
IconZoomQuestion,
} from "@tabler/icons-react";
import { getWidgetName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../components/integration-avatar";
import { SelectableCard } from "../components/selectable-card";
import { selectGridCols } from "../select-grid-layout";
import { buildSortedIntegrations, filterIntegrations } from "./integration-grid-shared";
import type { IntegrationGridItem } from "./integration-grid-shared";
export interface IntegrationSelectGridProps {
onSelect: (kind: IntegrationKind) => void;
enableMockIntegration?: boolean;
allowedKinds?: readonly IntegrationKind[];
integrationData?: { kind: IntegrationKind; name?: string }[];
}
export const IntegrationSelectGrid = ({
onSelect,
enableMockIntegration = false,
allowedKinds,
integrationData,
}: IntegrationSelectGridProps) => {
const [search, setSearch] = useState("");
const t = useI18n();
const integrations = useMemo(
() => buildSortedIntegrations({ enableMockIntegration, allowedKinds }),
[allowedKinds, enableMockIntegration],
);
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (
<Stack gap="md">
{/* Top Search Input */}
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
placeholder={`${t("integration.page.list.search")}...`}
aria-label={t("integration.page.list.search")}
data-autofocus
onKeyDown={(event) => {
if (event.key === "Enter" && filtered.length === 1 && filtered[0]) {
onSelect(filtered[0].kind);
}
}}
/>
{/* Scrollable Container with Integration Cards */}
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
<Stack gap="md" pt="xs" pr="xs" px={4}>
<SimpleGrid cols={selectGridCols} spacing="sm">
{filtered.map((integration) => (
<IntegrationCard
key={integration.kind}
integration={integration}
onSelect={onSelect}
connectedCount={(integrationData ?? []).filter((i) => i.kind === integration.kind).length}
/>
))}
</SimpleGrid>
{filtered.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
);
};
// =========================================================================
// IntegrationCard: Shared Modular SelectableCard with Tied Widgets
// =========================================================================
const IntegrationCard = ({
integration,
onSelect,
connectedCount = 0,
}: {
integration: IntegrationGridItem;
onSelect: (kind: IntegrationKind) => void;
connectedCount?: number;
}) => {
const t = useI18n();
return (
<SelectableCard
onClick={() => onSelect(integration.kind)}
aria-label={integration.name}
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
title={
<Text fw={700} size="md" style={{ whiteSpace: "nowrap" }}>
{integration.name}
</Text>
}
footerLeft={
connectedCount > 0 ? (
<Badge variant="light" color="teal" size="xs" radius="xs">
{t("integration.grid.connected", { count: connectedCount })}
</Badge>
) : null
}
>
<Text size="10px" tt="uppercase" fw={700} c="dimmed">
{t("integration.grid.tiedWidgets")}
</Text>
<IntegrationTiedWidgets widgets={integration.widgets} />
</SelectableCard>
);
};
const widgetIconsMap: Partial<Record<WidgetKind, typeof IconPuzzle>> = {
clock: IconClock,
weather: IconCloud,
airQuality: IconWind,
countdown: IconHourglass,
timer: IconClockPlay,
app: IconApps,
assistant: IconRobot,
iframe: IconBrowser,
video: IconDeviceCctv,
notebook: IconNotes,
anchorNote: IconNotes,
dnsHoleSummary: IconAd,
dnsHoleControls: IconDeviceGamepad,
"smartHome-entityState": IconBinaryTree,
"smartHome-executeAutomation": IconBinaryTree,
stockPrice: IconBuildingBank,
mediaServer: IconVideo,
calendar: IconCalendar,
downloads: IconDownload,
"mediaRequests-requestList": IconZoomQuestion,
"mediaRequests-requestStats": IconChartBar,
mediaTranscoding: IconTransform,
mediaMissing: IconMovie,
minecraftServerStatus: IconBrandMinecraft,
networkControllerSummary: IconTopologyFull,
networkControllerStatus: IconTopologyFull,
rssFeed: IconRss,
bookmarks: IconBookmark,
indexerManager: IconReportSearch,
healthMonitoring: IconHeartRateMonitor,
releases: IconRocket,
mediaReleases: IconTicket,
dockerContainers: IconBrandDocker,
firewall: IconWall,
notifications: IconMessage,
systemResources: IconGraphFilled,
coolify: IconCloud,
systemDisks: IconServer2,
timetable: IconBusStop,
};
const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; limit?: number }) => {
const t = useI18n();
if (widgets.length === 0) {
return (
<Text size="xs" c="dimmed" fs="italic">
{t("integration.grid.noWidgets")}
</Text>
);
}
const items = limit ? widgets.slice(0, limit) : widgets;
const moreCount = limit ? widgets.length - limit : 0;
return (
<Group gap={4} wrap="wrap">
{items.map((widgetKind) => {
const WidgetIcon = widgetIconsMap[widgetKind] ?? IconPuzzle;
return (
<Badge
key={widgetKind}
variant="default"
radius="xs"
size="xs"
leftSection={<WidgetIcon size={12} />}
style={{ fontWeight: 500 }}
>
{getWidgetName(widgetKind, t)}
</Badge>
);
})}
{moreCount > 0 && (
<Badge variant="subtle" color="gray" size="xs" radius="xs">
{t("integration.grid.more", { count: moreCount })}
</Badge>
)}
</Group>
);
};