From dd7a4908a36a71c1d0d1e41524eb8fd9bf53ca4d Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 5 Jun 2026 16:50:35 +0200 Subject: [PATCH] feat(onboarding): enhance integration setup with Docker discovery and URL templating (#5607) --- .../boards/(content)/_header-actions.tsx | 11 + .../init/_components/init-step-card.tsx | 12 + .../src/app/[locale]/init/_constants.ts | 2 + .../init/_steps/finish/init-finish.tsx | 30 +- .../[locale]/init/_steps/group/init-group.tsx | 8 +- .../init/_steps/import/file-info-card.tsx | 8 +- .../init/_steps/import/init-import.tsx | 9 +- .../integrations/docker-discovery-panel.tsx | 209 ++++++++ .../_steps/integrations/init-integrations.tsx | 483 ++++++++++++++++++ .../init/_steps/settings/init-settings.tsx | 14 +- .../[locale]/init/_steps/start/init-start.tsx | 7 +- .../[locale]/init/_steps/user/init-user.tsx | 7 +- apps/nextjs/src/app/[locale]/init/page.tsx | 5 +- .../new/_integration-new-dropdown.tsx | 59 --- .../new/_integration-new-form-wrapper.tsx | 26 + .../new/_integration-new-form.tsx | 175 +++++-- .../new/_integration-new-select-grid.tsx | 21 + .../[locale]/manage/integrations/new/page.tsx | 32 +- .../app/[locale]/manage/integrations/page.tsx | 54 +- .../manage/tools/logs/font-size-slider.tsx | 29 ++ .../manage/tools/logs/log-context.tsx | 25 +- .../app/[locale]/manage/tools/logs/page.tsx | 6 +- .../[locale]/manage/tools/logs/terminal.tsx | 16 +- .../board/items/item-move-modal.tsx | 19 +- .../board/items/item-select-modal.tsx | 121 +++-- .../sections/category/category-edit-modal.tsx | 15 +- .../sections/dynamic/dynamic-edit-modal.tsx | 17 +- .../integration/integration-grid-shared.ts | 95 ++++ .../integration-multi-select-grid.tsx | 140 +++++ .../integration/integration-select-grid.tsx | 90 ++++ .../integration/integration-select-modal.tsx | 56 ++ e2e/onboarding.spec.ts | 2 + e2e/shared/actions/onboarding-actions.ts | 5 + .../assertions/onboarding-assertions.ts | 4 +- .../api/src/router/onboard/onboard-queries.ts | 3 + .../api/src/router/onboard/onboard-router.ts | 296 ++++++++++- packages/common/src/url.ts | 6 +- packages/db/migrations/seed.ts | 93 +++- packages/db/queries/index.ts | 1 + packages/db/queries/widget-placement.ts | 148 ++++++ .../src/docker-integration-match.ts | 90 ++++ packages/definitions/src/index.ts | 3 + .../src/integration-url-template.ts | 41 ++ packages/definitions/src/integration.ts | 68 ++- packages/definitions/src/onboarding.ts | 2 +- .../definitions/src/test/api-key-url.spec.ts | 29 ++ .../src/test/docker-integration-match.spec.ts | 119 +++++ .../src/test/integration-url-template.spec.ts | 41 ++ .../definitions/src/widget-integration-map.ts | 113 ++++ .../src/apps/app-select-modal.tsx | 180 ++++--- .../quick-add-app/quick-add-app-modal.tsx | 3 +- packages/modals/index.ts | 2 + packages/modals/src/modal-form-footer.tsx | 53 ++ packages/modals/src/modal-sizes.ts | 1 + .../src/components/initial-oldmarr-import.tsx | 2 +- .../initial/board-selection-card.tsx | 2 +- .../initial/import-settings-card.tsx | 2 +- .../initial/import-summary-card.tsx | 2 +- packages/request-handler/src/docker.ts | 3 +- packages/translation/src/lang/en.json | 78 +++ packages/ui/src/components/index.tsx | 6 + packages/ui/src/index.ts | 1 + packages/ui/src/select-grid-layout.tsx | 50 ++ packages/ui/src/theme.ts | 3 + packages/ui/src/theme/modal.ts | 33 ++ .../src/bookmarks/app-select-modal.tsx | 15 +- .../modals/widget-advanced-options-modal.tsx | 14 +- .../widgets/src/modals/widget-edit-modal.tsx | 41 +- 68 files changed, 2922 insertions(+), 434 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/init/_components/init-step-card.tsx create mode 100644 apps/nextjs/src/app/[locale]/init/_constants.ts create mode 100644 apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx create mode 100644 apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx delete mode 100644 apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-dropdown.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-select-grid.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/logs/font-size-slider.tsx create mode 100644 apps/nextjs/src/components/integration/integration-grid-shared.ts create mode 100644 apps/nextjs/src/components/integration/integration-multi-select-grid.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 create mode 100644 packages/db/queries/widget-placement.ts create mode 100644 packages/definitions/src/docker-integration-match.ts create mode 100644 packages/definitions/src/integration-url-template.ts create mode 100644 packages/definitions/src/test/api-key-url.spec.ts create mode 100644 packages/definitions/src/test/docker-integration-match.spec.ts create mode 100644 packages/definitions/src/test/integration-url-template.spec.ts create mode 100644 packages/definitions/src/widget-integration-map.ts create mode 100644 packages/modals/src/modal-form-footer.tsx create mode 100644 packages/modals/src/modal-sizes.ts create mode 100644 packages/ui/src/select-grid-layout.tsx create mode 100644 packages/ui/src/theme/modal.ts diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 9df73c960..38ca9a96b 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -11,6 +11,7 @@ import { IconLayoutBoard, IconPencil, IconPencilOff, + IconPlug, IconPlus, IconReplace, IconResize, @@ -36,6 +37,7 @@ import { useBoardPermissions } from "~/components/board/permissions/client"; import { useCategoryActions } from "~/components/board/sections/category/category-actions"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; +import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import { HeaderButton } from "~/components/layout/header/button"; export const BoardContentHeaderActions = () => { @@ -70,6 +72,7 @@ const AddMenu = () => { const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); + const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); const { addCategoryToEnd } = useCategoryActions(); const { addDynamicSection } = useDynamicSectionActions(); const { createItem } = useItemActions(); @@ -111,6 +114,10 @@ const AddMenu = () => { }); }, [openAppSelectModal, createItem]); + const handleAddIntegration = useCallback(() => { + openIntegrationSelectModal({}); + }, [openIntegrationSelectModal]); + return ( @@ -130,6 +137,10 @@ const AddMenu = () => { {t("app.action.add")} + } onClick={handleAddIntegration}> + {t("integration.action.create")} + + } onClick={handleAddCategory}> diff --git a/apps/nextjs/src/app/[locale]/init/_components/init-step-card.tsx b/apps/nextjs/src/app/[locale]/init/_components/init-step-card.tsx new file mode 100644 index 000000000..e51149147 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_components/init-step-card.tsx @@ -0,0 +1,12 @@ +import type { PropsWithChildren } from "react"; +import { Card } from "@mantine/core"; + +interface InitStepCardProps { + withBorder?: boolean; +} + +export const InitStepCard = ({ children, withBorder = true }: PropsWithChildren) => ( + + {children} + +); diff --git a/apps/nextjs/src/app/[locale]/init/_constants.ts b/apps/nextjs/src/app/[locale]/init/_constants.ts new file mode 100644 index 000000000..870cbdc21 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_constants.ts @@ -0,0 +1,2 @@ +export const onboardingContentWidth = 64 * 16; +export const onboardingMaxWidth = "90vw"; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/finish/init-finish.tsx b/apps/nextjs/src/app/[locale]/init/_steps/finish/init-finish.tsx index 133581d58..ff7c2042a 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/finish/init-finish.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/finish/init-finish.tsx @@ -1,39 +1,27 @@ import type { MantineColor } from "@mantine/core"; -import { Button, Card, Stack, Text } from "@mantine/core"; -import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react"; +import { Button, Stack, Text } from "@mantine/core"; +import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react"; import { isProviderEnabled } from "@homarr/auth/server"; import { getMantineColor } from "@homarr/common"; -import { db } from "@homarr/db"; import { createDocumentationLink } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; import { Link } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; +import { InitStepCard } from "../../_components/init-step-card"; + export const InitFinish = async () => { - const firstBoard = await db.query.boards.findFirst({ columns: { name: true } }); const tFinish = await getScopedI18n("init.step.finish"); return ( - + {tFinish("description")} - {firstBoard ? ( - - {tFinish("action.goToBoard", { name: firstBoard.name })} - - ) : ( - - {tFinish("action.createBoard")} - - )} + + {tFinish("action.goToBoard", { name: "dashboard" })} + {isProviderEnabled("credentials") && ( { {tFinish("action.docs")} - + ); }; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/group/init-group.tsx b/apps/nextjs/src/app/[locale]/init/_steps/group/init-group.tsx index 94424ef7d..98602de73 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/group/init-group.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/group/init-group.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Card, Stack, TextInput } from "@mantine/core"; +import { Button, Stack, TextInput } from "@mantine/core"; import { IconArrowRight } from "@tabler/icons-react"; import type { z } from "zod/v4"; @@ -10,6 +10,8 @@ import { useZodForm } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; import { groupCreateSchema } from "@homarr/validation/group"; +import { InitStepCard } from "../../_components/init-step-card"; + export const InitGroup = () => { const t = useI18n(); const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({ @@ -34,7 +36,7 @@ export const InitGroup = () => { }; return ( - +
{
-
+ ); }; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/import/file-info-card.tsx b/apps/nextjs/src/app/[locale]/init/_steps/import/file-info-card.tsx index 3acbe646b..8a3a14fb4 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/import/file-info-card.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/import/file-info-card.tsx @@ -1,10 +1,12 @@ -import { ActionIcon, Button, Card, Group, Text } from "@mantine/core"; +import { ActionIcon, Button, Group, Text } from "@mantine/core"; import type { FileWithPath } from "@mantine/dropzone"; import { IconPencil } from "@tabler/icons-react"; import { humanFileSize } from "@homarr/common"; import { useScopedI18n } from "@homarr/translation/client"; +import { InitStepCard } from "../../_components/init-step-card"; + interface FileInfoCardProps { file: FileWithPath; onRemove: () => void; @@ -13,7 +15,7 @@ interface FileInfoCardProps { export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => { const tFileInfo = useScopedI18n("init.step.import.fileInfo"); return ( - + @@ -36,6 +38,6 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => { - + ); }; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx b/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx index cdb3faf50..c7113b66b 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/import/init-import.tsx @@ -1,13 +1,14 @@ "use client"; import { startTransition, useState } from "react"; -import { Card, Stack } from "@mantine/core"; +import { Stack } from "@mantine/core"; import type { FileWithPath } from "@mantine/dropzone"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { InitialOldmarrImport } from "@homarr/old-import/components"; +import { InitStepCard } from "../../_components/init-step-card"; import { FileInfoCard } from "./file-info-card"; import { ImportDropZone } from "./import-dropzone"; @@ -20,7 +21,7 @@ export const InitImport = () => { if (!file) { return ( - + { @@ -40,12 +41,12 @@ export const InitImport = () => { }); }} /> - + ); } return ( - + setFile(null)} /> {analyseResult !== null && } diff --git a/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx b/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx new file mode 100644 index 000000000..88da9cb71 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + Avatar, + Badge, + Card, + Collapse, + Group, + Loader, + Paper, + ScrollArea, + SimpleGrid, + Stack, + Text, + ThemeIcon, + UnstyledButton, +} from "@mantine/core"; +import { IconBrandDocker, IconCheck, IconChevronDown } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { getIntegrationName } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; +import { IntegrationAvatar } from "@homarr/ui"; + +export interface DiscoveredIntegration { + containerId: string; + containerName: string; + kind: IntegrationKind; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; +} + +export interface DiscoveredApp { + containerId: string; + containerName: string; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; +} + +interface DockerDiscoveryIndicatorProps { + integrations: DiscoveredIntegration[]; + apps: DiscoveredApp[]; + selectedAppIds: Set; + onToggleApp: (containerId: string) => void; + isLoading: boolean; +} + +const APP_CARD_HEIGHT = 80; + +export const DockerDiscoveryIndicator = ({ + integrations, + apps, + selectedAppIds, + onToggleApp, + isLoading, +}: DockerDiscoveryIndicatorProps) => { + const hasApps = apps.length > 0; + const [expanded, setExpanded] = useState(false); + + const expandedOnce = useRef(false); + useEffect(() => { + if ((hasApps || integrations.length > 0) && !expandedOnce.current) { + expandedOnce.current = true; + setExpanded(true); + } + }, [hasApps, integrations.length]); + + const t = useScopedI18n("init.step.integrations.docker"); + const totalCount = integrations.length + apps.length; + + if (!isLoading && totalCount === 0) return null; + + const selectedAppCount = apps.filter((app) => selectedAppIds.has(app.containerId)).length; + + if (isLoading) { + return ( + + + + + + + + {t("scanning")} + + + {t("scanningDescription")} + + + + + + ); + } + + return ( + + setExpanded((prev) => !prev)} w="100%"> + + + + + + + + {t("detected", { count: String(totalCount) })} + + + {t("detectedBreakdown", { + integrations: String(integrations.length), + apps: String(apps.length), + })} + + + + + + + + + + {integrations.length > 0 && ( + + {integrations.map((integration) => ( + + + + {integration.containerName} + + + {getIntegrationName(integration.kind)} + + + ))} + + )} + + {hasApps && ( + + + {t("appSectionLabel", { count: String(selectedAppCount) })} + + + + {apps.map((app) => { + const isSelected = selectedAppIds.has(app.containerId); + return ( + onToggleApp(app.containerId)} + > + + + + {app.containerName} + + {isSelected && } + + + {app.containerName.at(0)?.toUpperCase()} + + + + ); + })} + + + + )} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx new file mode 100644 index 000000000..71f9345f3 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx @@ -0,0 +1,483 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + ActionIcon, + Alert, + Avatar, + Button, + Card, + Center, + Collapse, + Group, + Loader, + Paper, + Progress, + SegmentedControl, + SimpleGrid, + Stack, + Text, + TextInput, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { + IconAlertTriangle, + IconArrowRight, + IconBrandDocker, + IconCheck, + IconChevronDown, + IconSettings, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions"; +import { buildAppUrl, buildIntegrationUrl, getIntegrationName } from "@homarr/definitions"; +import { showErrorNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; +import { IntegrationAvatar } from "@homarr/ui"; + +import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; +import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid"; +import { InitStepCard } from "../../_components/init-step-card"; + +type Phase = "select" | "configure" | "done"; + +interface IntegrationDraft { + kind: IntegrationKind; + initialUrl: string; + initialName?: string; + dockerPort?: number | null; + source: "manual" | "docker"; +} + +export const InitIntegrations = () => { + const [phase, setPhase] = useState("select"); + const [drafts, setDrafts] = useState>(new Map()); + const [selectedKinds, setSelectedKinds] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [baseHost, setBaseHost] = useState(""); + const [urlMode, setUrlMode] = useState("subdomain"); + const t = useScopedI18n("init.step.integrations"); + const router = useRouter(); + + const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation(); + const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation(); + + const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery( + undefined, + { + retry: false, + refetchOnWindowFocus: false, + }, + ); + + const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : []; + const dockerApps = dockerData?.status === "success" ? dockerData.apps : []; + const totalDetected = dockerIntegrations.length + dockerApps.length; + + const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]); + + const [selectedAppIds, setSelectedAppIds] = useState>(new Set()); + const [appsExpanded, setAppsExpanded] = useState(false); + + const dockerApplied = useRef(false); + useEffect(() => { + if (dockerData?.status !== "success" || dockerApplied.current) return; + dockerApplied.current = true; + + const newDrafts = new Map(drafts); + const newKinds = new Set(selectedKinds); + + for (const item of dockerData.integrations) { + if (!newDrafts.has(item.kind)) { + newDrafts.set(item.kind, { + kind: item.kind, + initialUrl: item.suggestedUrl, + initialName: item.containerName, + dockerPort: item.publishedPort, + source: "docker", + }); + } + newKinds.add(item.kind); + } + + setDrafts(newDrafts); + setSelectedKinds(Array.from(newKinds)); + setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId))); + if (dockerData.apps.length > 0) { + setAppsExpanded(true); + } + }, [dockerData]); + + const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => { + setSelectedKinds(kinds); + }, []); + + const handleToggleApp = useCallback((containerId: string) => { + setSelectedAppIds((prev) => { + const next = new Set(prev); + if (next.has(containerId)) { + next.delete(containerId); + } else { + next.add(containerId); + } + return next; + }); + }, []); + + const handleContinueToConfig = () => { + const newDrafts = new Map(drafts); + for (const kind of selectedKinds) { + const existing = newDrafts.get(kind); + if (baseHost) { + newDrafts.set(kind, { + kind, + initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined), + initialName: existing?.initialName, + dockerPort: existing?.dockerPort, + source: existing?.source ?? "manual", + }); + } else if (!existing) { + newDrafts.set(kind, { kind, initialUrl: "", source: "manual" }); + } + } + setDrafts(newDrafts); + setCurrentIndex(0); + setPhase("configure"); + }; + + const finishSetupAsync = async () => { + setPhase("done"); + try { + const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId)); + if (appsToCreate.length > 0) { + await createApps( + appsToCreate.map((app) => { + const href = baseHost + ? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined) + : app.suggestedUrl; + return { + name: app.containerName, + href: href || null, + iconUrl: app.iconUrl, + }; + }), + ); + } + await setupIntegrations(); + router.refresh(); + } catch { + showErrorNotification({ + title: t("configure.error.title"), + message: t("configure.error.message"), + }); + setPhase("select"); + } + }; + + const advanceOrFinish = () => { + if (currentIndex + 1 >= selectedKinds.length) { + void finishSetupAsync(); + } else { + setCurrentIndex((i) => i + 1); + } + }; + + const backToSelect = () => { + setPhase("select"); + setCurrentIndex(0); + }; + + if (phase === "done") { + return ( + +
+ + + {t("configure.finishing")} + +
+
+ ); + } + + if (phase === "configure") { + return ( + + ); + } + + const selectedAppCount = dockerApps.filter((app) => selectedAppIds.has(app.containerId)).length; + + return ( + + + {t("description")} + + } title={t("urlTemplate.label")}> + + + {t("urlTemplate.warning")} + + + setBaseHost(e.currentTarget.value)} + data-autofocus + /> + setUrlMode(value as UrlTemplateMode)} + data={[ + { label: t("urlTemplate.mode.subdomain"), value: "subdomain" }, + { label: t("urlTemplate.mode.hostPort"), value: "hostPort" }, + ]} + style={{ flex: "0 0 auto" }} + /> + + {baseHost && ( + + {t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) })} + + )} + + + + {isDockerLoading && ( + + + + + + + {t("docker.scanning")} + + + + + )} + + {!isDockerLoading && totalDetected > 0 && ( + + + + + + + {t("docker.summary", { + apps: String(totalDetected), + integrations: String(dockerIntegrations.length), + })} + + + + )} + + + + {dockerApps.length > 0 && ( + + + setAppsExpanded((prev) => !prev)} + > + + {t("docker.appSectionLabel", { count: String(selectedAppCount) })} + + + + + + {dockerApps.map((app) => { + const isSelected = selectedAppIds.has(app.containerId); + return ( + handleToggleApp(app.containerId)} + > + + + + {app.containerName} + + {isSelected && } + + + {app.containerName.at(0)?.toUpperCase()} + + + + ); + })} + + + + + )} + + + + + + + + ); +}; + +interface ConfigurePhaseProps { + selectedKinds: IntegrationKind[]; + currentIndex: number; + drafts: Map; + baseHost: string; + setBaseHost: (value: string) => void; + urlMode: UrlTemplateMode; + setUrlMode: (value: UrlTemplateMode) => void; + onSuccess: () => void; + onCancel: () => void; + onSkip: () => void; +} + +const ConfigurePhase = ({ + selectedKinds, + currentIndex, + drafts, + baseHost, + setBaseHost, + urlMode, + setUrlMode, + onSuccess, + onCancel, + onSkip, +}: ConfigurePhaseProps) => { + const t = useScopedI18n("init.step.integrations"); + const [helpersOpen, { toggle: toggleHelpers }] = useDisclosure(false); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds + const currentKind = selectedKinds[currentIndex]!; + const draft = drafts.get(currentKind); + const progress = ((currentIndex + 1) / selectedKinds.length) * 100; + const computedUrl = baseHost + ? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined) + : draft?.initialUrl; + + return ( + + + + + + {getIntegrationName(currentKind)} + + + + + + + + + + + + {t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })} + + + + + + + {t("configure.helpers.description")} + + + setBaseHost(e.currentTarget.value)} + /> + setUrlMode(value as UrlTemplateMode)} + data={[ + { label: t("urlTemplate.mode.subdomain"), value: "subdomain" }, + { label: t("urlTemplate.mode.hostPort"), value: "hostPort" }, + ]} + style={{ flex: "0 0 auto" }} + /> + + {baseHost && ( + + {t("urlTemplate.preview", { + example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined), + })} + + )} + + + + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/init/_steps/settings/init-settings.tsx b/apps/nextjs/src/app/[locale]/init/_steps/settings/init-settings.tsx index cb024fc2f..5c99d651c 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/settings/init-settings.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/settings/init-settings.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core"; +import { Button, Group, Stack, Switch, Text } from "@mantine/core"; import { IconArrowRight } from "@tabler/icons-react"; import type { z } from "zod/v4"; @@ -13,6 +13,8 @@ import type { TranslationObject } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { settingsInitSchema } from "@homarr/validation/settings"; +import { InitStepCard } from "../../_components/init-step-card"; + export const InitSettings = () => { const tSection = useScopedI18n("management.page.settings.section"); const t = useI18n(); @@ -29,8 +31,8 @@ export const InitSettings = () => { return (
- - + + {tSection("analytics.title")} @@ -38,8 +40,8 @@ export const InitSettings = () => { - - + + {tSection("crawlingAndIndexing.title")} @@ -62,7 +64,7 @@ export const InitSettings = () => { /> - + - - - + + {t("action.create")} + ) : undefined } floatingPrimaryAction={canCreateIntegrations} @@ -83,25 +72,6 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) { ); } -const IntegrationSelectMenu = ({ children }: PropsWithChildren) => { - return ( - - {children} - - - - - ); -}; - interface IntegrationListProps { integrations: RouterOutputs["integration"]["all"]; activeTab?: IntegrationKind; @@ -134,12 +104,12 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps return ( - {entries.map(([kind, integrations], index) => ( + {entries.map(([kind, kindIntegrations], index) => ( }> {getIntegrationName(kind)} - + @@ -152,7 +122,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps - {integrations.map((integration) => ( + {kindIntegrations.map((integration) => ( {integration.name} @@ -173,7 +143,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps > - + )} @@ -184,9 +154,9 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps - {integrations.map((integration, index) => ( + {kindIntegrations.map((integration, integrationIndex) => ( - {index !== 0 && } + {integrationIndex !== 0 && } {integration.name} @@ -201,7 +171,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps > - + )} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/font-size-slider.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/font-size-slider.tsx new file mode 100644 index 000000000..d76c3df9d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/font-size-slider.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Group, Slider, Text } from "@mantine/core"; +import { IconTextSize } from "@tabler/icons-react"; + +import { useLogContext } from "./log-context"; + +export const LogFontSizeSlider = () => { + const { fontSize, setFontSize } = useLogContext(); + + return ( + + + `${val}px`} + style={{ flex: 1 }} + size="xs" + /> + + {fontSize}px + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/log-context.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/log-context.tsx index 9a58ffaaa..2caa5da38 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/log-context.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/log-context.tsx @@ -6,10 +6,23 @@ import { createContext, useContext, useMemo, useState } from "react"; import type { LogLevel } from "@homarr/core/infrastructure/logs/constants"; import { logLevels } from "@homarr/core/infrastructure/logs/constants"; +const FONT_SIZE_KEY = "homarr-log-font-size"; +const DEFAULT_FONT_SIZE = 14; + +const readStoredFontSize = () => { + if (typeof window === "undefined") return DEFAULT_FONT_SIZE; + const stored = localStorage.getItem(FONT_SIZE_KEY); + if (!stored) return DEFAULT_FONT_SIZE; + const parsed = Number(stored); + return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 10), 24) : DEFAULT_FONT_SIZE; +}; + const LogContext = createContext<{ level: LogLevel; setLevel: (level: LogLevel) => void; activeLevels: LogLevel[]; + fontSize: number; + setFontSize: (size: number) => void; } | null>(null); interface LogContextProviderProps extends PropsWithChildren { @@ -18,9 +31,19 @@ interface LogContextProviderProps extends PropsWithChildren { export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => { const [level, setLevel] = useState(defaultLevel); + const [fontSize, setFontSizeRaw] = useState(readStoredFontSize); const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]); - return {children}; + const setFontSize = (size: number) => { + setFontSizeRaw(size); + localStorage.setItem(FONT_SIZE_KEY, String(size)); + }; + + return ( + + {children} + + ); }; export const useLogContext = () => { diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx index b6901934c..a981f2f57 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/page.tsx @@ -13,6 +13,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { fullHeightWithoutHeaderAndFooter } from "~/constants"; import { createMetaTitle } from "~/metadata"; import { ClientSideTerminalComponent } from "./client"; +import { LogFontSizeSlider } from "./font-size-slider"; import { LogLevelSelection } from "./level-selection"; import { LogContextProvider } from "./log-context"; @@ -38,7 +39,10 @@ export default async function LogsManagementPage() { - + + + + diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx index 9e8c569af..97f6ad150 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx @@ -13,9 +13,11 @@ import classes from "./terminal.module.css"; export const TerminalComponent = () => { const ref = useRef(null); - const { activeLevels } = useLogContext(); + const { activeLevels, fontSize } = useLogContext(); const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + clientApi.log.subscribe.useSubscription( { levels: activeLevels, @@ -26,7 +28,6 @@ export const TerminalComponent = () => { terminalRef.current?.refresh(0, terminalRef.current.rows - 1); }, onError(err) { - // This makes sense as logging might cause an infinite loop alert(err); }, }, @@ -43,14 +44,14 @@ export const TerminalComponent = () => { cursorBlink: false, disableStdin: true, convertEol: true, + fontSize, }); terminalRef.current.open(ref.current); terminalRef.current.loadAddon(canvasAddon); - // This is a hack to make sure the terminal is rendered before we try to fit it - // You can blame @Meierschlumpf for this setTimeout(() => { const fitAddon = new FitAddon(); + fitAddonRef.current = fitAddon; terminalRef.current?.loadAddon(fitAddon); fitAddon.fit(); }); @@ -60,5 +61,12 @@ export const TerminalComponent = () => { canvasAddon.dispose(); }; }, []); + + useEffect(() => { + if (!terminalRef.current) return; + terminalRef.current.options.fontSize = fontSize; + fitAddonRef.current?.fit(); + }, [fontSize]); + return ; }; diff --git a/apps/nextjs/src/components/board/items/item-move-modal.tsx b/apps/nextjs/src/components/board/items/item-move-modal.tsx index c1e03ba7c..38e362ede 100644 --- a/apps/nextjs/src/components/board/items/item-move-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-move-modal.tsx @@ -1,11 +1,11 @@ import { useCallback, useRef } from "react"; -import { Button, Grid, Group, NumberInput, Stack } from "@mantine/core"; +import { Grid, NumberInput, Stack } from "@mantine/core"; import { z } from "zod/v4"; import { useZodForm } from "@homarr/form"; import type { GridStack } from "@homarr/gridstack"; -import { createModal } from "@homarr/modals"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; +import { useI18n } from "@homarr/translation/client"; import type { Item, SectionItem } from "~/app/[locale]/boards/_types"; @@ -16,9 +16,7 @@ interface InnerProps { } export const ItemMoveModal = createModal(({ actions, innerProps }) => { - const tCommon = useScopedI18n("common"); const t = useI18n(); - // Keep track of the maximum width based on the x offset const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset); const form = useZodForm( z.object({ @@ -38,7 +36,6 @@ export const ItemMoveModal = createModal(({ actions, innerProps }) = height: innerProps.item.height, }, onValuesChange(values, previous) { - // Update the maximum width when the x offset changes if (values.xOffset !== previous.xOffset) { maxWidthRef.current = innerProps.columnCount - values.xOffset; } @@ -70,6 +67,7 @@ export const ItemMoveModal = createModal(({ actions, innerProps }) = (({ actions, innerProps }) = - - - - + ); @@ -106,5 +99,5 @@ export const ItemMoveModal = createModal(({ actions, innerProps }) = defaultTitle(t) { return t("item.moveResize.title"); }, - size: "lg", + size: modalSizeForm, }); diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 23c9ddec6..84947d150 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -1,14 +1,14 @@ import { useMemo, useState } from "react"; -import { Avatar, Button, Card, Center, Grid, Group, Input, Stack, Text, Tooltip } from "@mantine/core"; -import { IconSearch } from "@tabler/icons-react"; +import { Avatar, Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { createId, objectEntries } from "@homarr/common"; import { getIconUrl, getIntegrationName } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { createModal, useModalAction } from "@homarr/modals"; +import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; +import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetEditModal } from "@homarr/widgets/modals"; @@ -86,32 +86,31 @@ export const ItemSelectModal = createModal(({ actions }) => { }; return ( - - setSearch(event.currentTarget.value)} - leftSection={} - placeholder={`${t("item.create.search")}...`} - data-autofocus - onKeyDown={(event) => { - // Add item if there is only one item in the list and user presses Enter - if (event.key === "Enter" && filteredItems.length === 1) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - handleAdd(filteredItems[0]!.kind); - } - }} - /> + { + if (event.key === "Enter" && filteredItems.length === 1) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + handleAdd(filteredItems[0]!.kind); + } + }} + > + {filteredItems.map((item) => ( + handleAdd(item.kind)} /> + ))} - - {filteredItems.map((item) => ( - handleAdd(item.kind)} /> - ))} - - + {filteredItems.length === 0 && ( +
+ {t("common.noResults")} +
+ )} + ); }).withOptions({ defaultTitle: (t) => t("item.create.title"), - size: "xl", + size: modalSizeSelect, }); const WidgetItem = ({ @@ -130,46 +129,58 @@ const WidgetItem = ({ const t = useI18n(); return ( - - - - -
- -
- - {item.name} - - - {item.description} - -
- - -
-
-
+ e.currentTarget.style.setProperty("--_hover-opacity", "1")} + onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + > + + + + + {item.name} + + + + + {item.description} + + + + + + + + ); }; -interface SupportedIntegrationsProps { - integrations: IntegrationKind[]; - mt: string; -} - -const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) => { +const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => { if (integrations.length === 0) { return null; } - // When there are 8 or more integrations, we show 6 and a "+X" avatar. Otherwise, we show all integrations. const countToShow = integrations.length >= 8 ? 6 : 7; const moreCount = integrations.length - countToShow; return ( -
+ {integrations.slice(0, countToShow).map((integration) => ( @@ -195,6 +206,6 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) )} -
+ ); }; diff --git a/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx b/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx index 1f990eec0..fa14ea8ab 100644 --- a/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx +++ b/apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx @@ -1,8 +1,8 @@ -import { Button, Group, Stack, TextInput } from "@mantine/core"; +import { Stack, TextInput } from "@mantine/core"; import { z } from "zod/v4"; import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; +import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; interface Category { @@ -36,13 +36,10 @@ export const CategoryEditModal = createModal(({ actions, innerProps > - - - - + ); -}).withOptions({}); +}).withOptions({ + size: modalSizeForm, +}); diff --git a/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx b/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx index f80a833d6..c2f929b41 100644 --- a/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx +++ b/apps/nextjs/src/components/board/sections/dynamic/dynamic-edit-modal.tsx @@ -1,10 +1,10 @@ "use client"; -import { Button, CloseButton, ColorInput, Group, Stack, TextInput, useMantineTheme } from "@mantine/core"; +import { CloseButton, ColorInput, Stack, TextInput, useMantineTheme } from "@mantine/core"; import type { z } from "zod/v4"; import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; +import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; import { TextMultiSelect } from "@homarr/ui"; import { dynamicSectionOptionsSchema } from "@homarr/validation/shared"; @@ -31,7 +31,7 @@ export const DynamicSectionEditModal = createModal(({ actions, inner })} > - + (({ actions, inner } {...form.getInputProps("borderColor")} /> - - - - - - + ); @@ -63,5 +56,5 @@ export const DynamicSectionEditModal = createModal(({ actions, inner defaultTitle(t) { return t("item.edit.title"); }, - size: "lg", + size: modalSizeForm, }); diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts new file mode 100644 index 000000000..704449b34 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -0,0 +1,95 @@ +import { objectEntries } from "@homarr/common"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { + featuredIntegrations, + getIntegrationName, + hiddenFromOnboarding, + integrationDefs, + integrationKinds, +} from "@homarr/definitions"; +import { widgetImports } from "@homarr/widgets"; + +const buildWidgetsByIntegration = () => { + const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< + IntegrationKind, + WidgetKind[] + >; + + for (const [widgetKind, widget] of objectEntries(widgetImports)) { + const supported = + "supportedIntegrations" in widget.definition + ? (widget.definition.supportedIntegrations as IntegrationKind[]) + : []; + for (const kind of supported) { + if (kind in map) { + map[kind].push(widgetKind); + } + } + } + return map; +}; + +export const widgetsByIntegration = buildWidgetsByIntegration(); + +export const categoryTranslationKeys: Record = { + dnsHole: "integration.category.dnsHole", + mediaService: "integration.category.mediaService", + calendar: "integration.category.calendar", + mediaSearch: "integration.category.mediaSearch", + mediaRelease: "integration.category.mediaRelease", + mediaRequest: "integration.category.mediaRequest", + downloadClient: "integration.category.downloadClient", + usenet: "integration.category.usenet", + torrent: "integration.category.torrent", + miscellaneous: "integration.category.miscellaneous", + smartHomeServer: "integration.category.smartHomeServer", + indexerManager: "integration.category.indexerManager", + healthMonitoring: "integration.category.healthMonitoring", + search: "integration.category.search", + mediaTranscoding: "integration.category.mediaTranscoding", + networkController: "integration.category.networkController", + releasesProvider: "integration.category.releasesProvider", + notifications: "integration.category.notifications", + firewall: "integration.category.firewall", + timetable: "integration.category.timetable", + photoService: "integration.category.photoService", + notes: "integration.category.notes", + mediaMonitoring: "integration.category.mediaMonitoring", + speedtest: "integration.category.speedtest", + analytics: "integration.category.analytics", +}; + +export interface IntegrationGridItem { + kind: IntegrationKind; + name: string; + categories: string[]; + widgets: WidgetKind[]; +} + +export const buildSortedIntegrations = ( + options: { enableMockIntegration?: boolean; onboarding?: boolean } = {}, +): IntegrationGridItem[] => + integrationKinds + .filter((kind) => { + if (options.onboarding && hiddenFromOnboarding.has(kind)) return false; + if (options.onboarding && widgetsByIntegration[kind].length === 0) return false; + if (!options.enableMockIntegration && kind === "mock") return false; + return true; + }) + .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); + }); + +export const filterIntegrations = (items: IntegrationGridItem[], search: string) => + items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())); 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..cbd347ba1 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; + +import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared"; + +interface IntegrationMultiSelectGridProps { + selectedKinds: IntegrationKind[]; + onSelectionChange: (kinds: IntegrationKind[]) => void; + enableMockIntegration?: boolean; + onboarding?: boolean; + detectedKinds?: Set; +} + +export const IntegrationMultiSelectGrid = ({ + selectedKinds: selectedKindsArray, + onSelectionChange, + enableMockIntegration = false, + onboarding = false, + detectedKinds, +}: IntegrationMultiSelectGridProps) => { + const [search, setSearch] = useState(""); + const selectedKinds = useMemo(() => new Set(selectedKindsArray), [selectedKindsArray]); + const t = useI18n(); + + const toggleKind = useCallback( + (kind: IntegrationKind) => { + const current = new Set(selectedKindsArray); + if (current.has(kind)) { + current.delete(kind); + } else { + current.add(kind); + } + onSelectionChange(Array.from(current)); + }, + [selectedKindsArray, onSelectionChange], + ); + + const integrations = useMemo( + () => buildSortedIntegrations({ enableMockIntegration, onboarding }), + [enableMockIntegration, onboarding], + ); + + const sorted = useMemo(() => { + if (!detectedKinds || detectedKinds.size === 0) return integrations; + const detected = integrations.filter((i) => detectedKinds.has(i.kind)); + const rest = integrations.filter((i) => !detectedKinds.has(i.kind)); + return [...detected, ...rest]; + }, [integrations, detectedKinds]); + + const filtered = useMemo(() => filterIntegrations(sorted, search), [sorted, search]); + + return ( + + {filtered.map((integration) => { + const isSelected = selectedKinds.has(integration.kind); + const isDetected = detectedKinds?.has(integration.kind) ?? false; + return ( + toggleKind(integration.kind)} + withBorder + > + + + + + {integration.name} + + {isSelected && } + + + + {isDetected && ( + }> + {t("integration.grid.detected")} + + )} + + {integration.categories.slice(0, 2).map((cat) => ( + + {categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat} + + ))} + + {integration.widgets.length > 0 ? ( + + {integration.widgets.map((widgetKind) => ( + + {t(`widget.${widgetKind}.name`)} + + ))} + + } + > + }> + {t("integration.grid.widgetCount", { count: String(integration.widgets.length) })} + + + ) : ( + + {t("integration.grid.noWidgets")} + + )} + +
+ + ); + })} + + {filtered.length === 0 && ( +
+ {t("common.noResults")} +
+ )} + + ); +}; 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..f4905dd2a --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { IconPuzzle } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; + +import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared"; + +interface IntegrationSelectGridProps { + onSelect: (kind: IntegrationKind) => void; + enableMockIntegration?: boolean; +} + +export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => { + const [search, setSearch] = useState(""); + const t = useI18n(); + + const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]); + const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]); + + return ( + + {filtered.map((integration) => ( + onSelect(integration.kind)} + withBorder + > + + + + + {integration.name} + + + + + + {integration.categories.slice(0, 2).map((cat) => ( + + {categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat} + + ))} + + {integration.widgets.length > 0 ? ( + + {integration.widgets.map((widgetKind) => ( + + {t(`widget.${widgetKind}.name`)} + + ))} + + } + > + }> + {t("integration.grid.widgetCount", { count: String(integration.widgets.length) })} + + + ) : ( + + {t("integration.grid.noWidgets")} + + )} + +
+ + ))} + + {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..3410824f0 --- /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, modalSizeSelect } 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: modalSizeSelect, +}); diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts index 7e709f425..a8a09773e 100644 --- a/e2e/onboarding.spec.ts +++ b/e2e/onboarding.spec.ts @@ -31,6 +31,7 @@ describe("Onboarding", () => { confirmPassword: "Comp(exP4sswOrd", }); await actions.processSettingsStepAsync(); + await actions.processIntegrationsStepAsync(); // Assert await assertions.assertFinishStepVisibleAsync(); @@ -72,6 +73,7 @@ describe("Onboarding", () => { name: externalGroupName, }); await actions.processSettingsStepAsync(); + await actions.processIntegrationsStepAsync(); // Assert await assertions.assertFinishStepVisibleAsync(); diff --git a/e2e/shared/actions/onboarding-actions.ts b/e2e/shared/actions/onboarding-actions.ts index 6bdb1d2a5..56c09aba7 100644 --- a/e2e/shared/actions/onboarding-actions.ts +++ b/e2e/shared/actions/onboarding-actions.ts @@ -51,4 +51,9 @@ export class OnboardingActions { await this.page.waitForSelector("text=Analytics"); await this.page.locator("css=button[type='submit']").click(); } + + public async processIntegrationsStepAsync() { + await this.page.waitForSelector("text=integrations you'd like to connect"); + await this.page.locator("button", { hasText: "Skip for now" }).click(); + } } diff --git a/e2e/shared/assertions/onboarding-assertions.ts b/e2e/shared/assertions/onboarding-assertions.ts index 63d18ea7c..12cda67d0 100644 --- a/e2e/shared/assertions/onboarding-assertions.ts +++ b/e2e/shared/assertions/onboarding-assertions.ts @@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"; import { expect } from "vitest"; import * as sqliteSchema from "../../../packages/db/schema/sqlite"; -import { OnboardingStep } from "../../../packages/definitions/src"; +import type { OnboardingStep } from "../../../packages/definitions/src"; import { credentialsAdminGroup } from "../../../packages/definitions/src/group"; import type { SqliteDatabase } from "../e2e-db"; @@ -57,6 +57,6 @@ export class OnboardingAssertions { } public async assertFinishStepVisibleAsync() { - await this.page.waitForSelector("text=completed the setup", { timeout: 5000 }); + await this.page.waitForSelector("text=completed the setup", { timeout: 15000 }); } } diff --git a/packages/api/src/router/onboard/onboard-queries.ts b/packages/api/src/router/onboard/onboard-queries.ts index 15f205d8b..242d861e4 100644 --- a/packages/api/src/router/onboard/onboard-queries.ts +++ b/packages/api/src/router/onboard/onboard-queries.ts @@ -76,6 +76,9 @@ const nextSteps: Partial { + const port = ports?.[0]; + if (port?.PublicPort) { + const ip = port.IP && port.IP !== "0.0.0.0" && port.IP !== "::" ? port.IP : host; + return { url: `http://${ip}:${port.PublicPort}`, publishedPort: port.PublicPort }; + } + return { url: "", publishedPort: null }; +}; + export const onboardRouter = createTRPCRouter({ currentStep: publicProcedure.query(async ({ ctx }) => { return await getOnboardingOrFallbackAsync(ctx.db); @@ -33,4 +78,249 @@ export const onboardRouter = createTRPCRouter({ step: "start", }); }), + createIntegration: onboardingProcedure + .requiresStep("integrations") + .input( + z.object({ + name: z.string().nonempty().max(127), + url: z.string().nonempty(), + kind: zodEnumFromArray(integrationKinds), + secrets: z.array( + z.object({ + kind: zodEnumFromArray(integrationSecretKinds), + value: z.string().nonempty(), + }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + const result = await testConnectionAsync({ + id: "new", + name: input.name, + url: input.url, + kind: input.kind, + secrets: input.secrets, + }).catch((error) => { + if (!(error instanceof MissingSecretError)) throw error; + + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.message, + }); + }); + + if (!result.success) { + return { error: mapTestConnectionError(result.error) }; + } + + const integrationId = createId(); + const appId = createId(); + + await ctx.db.insert(apps).values({ + id: appId, + name: input.name, + iconUrl: getIconUrl(input.kind), + href: input.url, + pingUrl: input.url, + description: null, + }); + + await ctx.db.insert(integrations).values({ + id: integrationId, + name: input.name, + url: input.url, + kind: input.kind, + appId, + }); + + if (input.secrets.length >= 1) { + await ctx.db.insert(integrationSecrets).values( + input.secrets.map((secret) => ({ + kind: secret.kind, + value: encryptSecret(secret.value), + integrationId, + })), + ); + } + }), + discoverDockerServices: onboardingProcedure.requiresStep("integrations").query(async ({ ctx }) => { + const emptyResult = { + integrations: [] as { + containerId: string; + containerName: string; + kind: IntegrationKind; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; + }[], + apps: [] as { + containerId: string; + containerName: string; + suggestedUrl: string; + publishedPort: number | null; + iconUrl: string | null; + }[], + }; + + try { + const { DockerSingleton, dockerLabels } = await import("@homarr/docker"); + + const dockerInstances = DockerSingleton.getInstances(); + if (dockerInstances.length === 0) { + return { status: "empty" as const, ...emptyResult }; + } + + const results = await Promise.allSettled( + dockerInstances.map(async ({ instance, host }) => { + const containerList = await instance.listContainers({ all: false }); + return containerList.filter((c) => !(dockerLabels.hide in c.Labels)).map((c) => ({ ...c, host })); + }), + ); + + const containers = results.flatMap((r) => (r.status === "fulfilled" ? r.value : [])); + + if (containers.length === 0) { + return { status: "empty" as const, ...emptyResult }; + } + + const likeQueries = containers.map((c) => like(icons.name, `%${extractContainerImageName(c.Image)}%`)); + const dbIcons = likeQueries.length > 0 ? await ctx.db.query.icons.findMany({ where: or(...likeQueries) }) : []; + + const seenKinds = new Set(); + const discoveredIntegrations = emptyResult.integrations; + const discoveredApps = emptyResult.apps; + + const cdnIconUrl = (slug: string) => + `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/${slug}.svg`; + + const strictIconMatch = (imageName: string) => { + const normalized = imageName.toLowerCase().trim(); + if (normalized.length < 3) return null; + const exact = dbIcons.find((icon) => icon.name.toLowerCase() === normalized); + if (exact) return exact.url; + return dbIcons.find((icon) => icon.name.toLowerCase().startsWith(normalized))?.url ?? null; + }; + + for (const container of containers) { + const imageName = extractContainerImageName(container.Image); + const containerName = container.Names[0]?.split("/")[1] ?? "Unknown"; + const dbIcon = strictIconMatch(imageName); + const iconUrl = dbIcon ?? cdnIconUrl(imageName.toLowerCase()); + + const { url: suggestedUrl, publishedPort } = buildSuggestedUrl(container.Ports, container.host); + + const kind = matchIntegrationKindFromContainer({ + image: container.Image, + name: containerName, + }); + + if (kind && !seenKinds.has(kind)) { + seenKinds.add(kind); + discoveredIntegrations.push({ + containerId: container.Id, + containerName, + kind, + suggestedUrl, + publishedPort, + iconUrl, + }); + } else if (!kind && dbIcon) { + discoveredApps.push({ + containerId: container.Id, + containerName, + suggestedUrl, + publishedPort, + iconUrl, + }); + } + } + + return { + status: "success" as const, + integrations: discoveredIntegrations, + apps: discoveredApps, + }; + } catch { + return { status: "unavailable" as const, ...emptyResult }; + } + }), + createAppsFromDiscovery: onboardingProcedure + .requiresStep("integrations") + .input( + z.array( + z.object({ + name: z.string().nonempty().max(127), + href: z.string().nullable(), + iconUrl: z.string().nullable(), + }), + ), + ) + .mutation(async ({ ctx, input }) => { + if (input.length === 0) return; + const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg"; + await ctx.db.insert(apps).values( + input.map((app) => ({ + id: createId(), + name: app.name, + iconUrl: app.iconUrl ?? defaultIcon, + href: app.href, + description: null, + })), + ); + }), + setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => { + const db = ctx.db; + + const allIntegrations = await db.query.integrations.findMany(); + const allApps = await db.query.apps.findMany(); + + const board = await db.query.boards.findFirst({ + with: { + sections: true, + layouts: true, + }, + }); + + if (!board) { + await nextOnboardingStepAsync(db, undefined); + return; + } + + const everyoneGroupRow = await db.query.groups.findFirst({ + where: eq(groups.name, everyoneGroup), + }); + if (everyoneGroupRow && !everyoneGroupRow.homeBoardId) { + await db.update(groups).set({ homeBoardId: board.id }).where(eq(groups.id, everyoneGroupRow.id)); + } + + const section = board.sections.find((boardSection) => boardSection.kind === "empty"); + const layout = board.layouts[0]; + + if (section && layout) { + const existingItems = await db.query.items.findMany({ + where: eq(items.boardId, board.id), + }); + for (const item of existingItems) { + await db.delete(integrationItems).where(eq(integrationItems.itemId, item.id)); + await db.delete(itemLayouts).where(eq(itemLayouts.itemId, item.id)); + } + if (existingItems.length > 0) { + await db.delete(items).where(eq(items.boardId, board.id)); + } + + await placeAllWidgetsAsync( + db, + { + boardId: board.id, + sectionId: section.id, + layoutId: layout.id, + columnCount: layout.columnCount, + }, + allIntegrations, + allApps, + ); + } + + await nextOnboardingStepAsync(db, undefined); + }), }); diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts index 7550def44..f13cc1efc 100644 --- a/packages/common/src/url.ts +++ b/packages/common/src/url.ts @@ -1,7 +1,11 @@ import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; export const removeTrailingSlash = (path: string) => { - return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path; + let end = path.length; + while (end > 0 && path.charAt(end - 1) === "/") { + end--; + } + return path.slice(0, end); }; export const extractBaseUrlFromHeaders = ( diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 0a00e9674..270f4c6c9 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -4,6 +4,7 @@ import { createId, objectKeys } from "@homarr/common"; import { createDocumentationLink, credentialsAdminGroup, + defaultBookmarkApps, everyoneGroup, getIntegrationDefaultUrl, getIntegrationName, @@ -15,7 +16,7 @@ import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server import type { Database } from ".."; import { eq } from ".."; -import { getMaxGroupPositionAsync } from "../queries"; +import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries"; import { getServerSettingByKeyAsync, insertServerSettingByKeyAsync, @@ -46,6 +47,9 @@ export const seedDataAsync = async (db: Database) => { await seedServerSettingsAsync(db); await seedDefaultSearchEnginesAsync(db); await seedDefaultIntegrationsAsync(db); + await seedDefaultAppsAsync(db); + await seedDefaultBoardAsync(db); + await seedBoardWidgetsAsync(db); if (["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase())) { await seedDemoUserAsync(db); @@ -217,6 +221,64 @@ const seedDefaultIntegrationsAsync = async (db: Database) => { console.log(`Created ${createdCount} default integrations through seeding process`); }; +const seedDefaultAppsAsync = async (db: Database) => { + const existingApps = await db.$count(apps); + if (existingApps > 0) { + console.log("Skipping seeding of default apps as some already exist"); + return; + } + + for (const app of defaultBookmarkApps) { + await db.insert(apps).values({ + id: createId(), + name: app.name, + iconUrl: app.iconUrl, + href: app.href, + }); + } + console.log(`Created ${defaultBookmarkApps.length} default apps through seeding process`); +}; + +const seedDefaultBoardAsync = async (db: Database) => { + const existingBoard = await db.query.boards.findFirst(); + + if (existingBoard) { + console.log("Skipping seeding of default board as one already exists"); + return; + } + + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: "dashboard", + isPublic: false, + }); + await db.insert(sections).values({ + id: createId(), + kind: "empty", + xOffset: 0, + yOffset: 0, + boardId, + }); + await db.insert(layouts).values({ + id: createId(), + name: "Base", + columnCount: 10, + breakpoint: 0, + boardId, + }); + + const everyoneGroupRow = await db.query.groups.findFirst({ + where: eq(groups.name, everyoneGroup), + }); + if (everyoneGroupRow) { + await db.update(groups).set({ homeBoardId: boardId }).where(eq(groups.id, everyoneGroupRow.id)); + console.log("Set default board as home board for everyone group"); + } + + console.log("Created default board 'dashboard' through seed"); +}; + interface DemoWidget { kind: WidgetKind; width: number; @@ -475,3 +537,32 @@ const seedDemoUserAsync = async (db: Database) => { "Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.", ); }; + +const seedBoardWidgetsAsync = async (db: Database) => { + const existingItems = await db.$count(items); + if (existingItems > 0) { + console.log("Skipping seeding of board widgets as some already exist"); + return; + } + + const board = await db.query.boards.findFirst({ + with: { sections: true, layouts: true }, + }); + if (!board) return; + + const section = board.sections.find((sec) => sec.kind === "empty"); + const layout = board.layouts[0]; + if (!section || !layout) return; + + const allIntegrations = await db.query.integrations.findMany(); + const allApps = await db.query.apps.findMany(); + + const count = await placeAllWidgetsAsync( + db, + { boardId: board.id, sectionId: section.id, layoutId: layout.id, columnCount: layout.columnCount }, + allIntegrations, + allApps, + ); + + console.log(`Placed ${count} widgets on board`); +}; diff --git a/packages/db/queries/index.ts b/packages/db/queries/index.ts index 13e90d6eb..368d7eaa2 100644 --- a/packages/db/queries/index.ts +++ b/packages/db/queries/index.ts @@ -1,3 +1,4 @@ export * from "./item"; export * from "./server-setting"; export * from "./group"; +export * from "./widget-placement"; diff --git a/packages/db/queries/widget-placement.ts b/packages/db/queries/widget-placement.ts new file mode 100644 index 000000000..1d0b529f9 --- /dev/null +++ b/packages/db/queries/widget-placement.ts @@ -0,0 +1,148 @@ +import superjson from "superjson"; + +import { createId } from "@homarr/common"; +import { + defaultBookmarkApps, + defaultWidgetConfigs, + emptySuperJSON, + getWidgetKindsForIntegration, +} from "@homarr/definitions"; +import type { WidgetKind } from "@homarr/definitions"; + +import type { Database } from ".."; +import { integrationItems, itemLayouts, items } from "../schema"; +import type { Integration } from "../schema"; + +const widgetConfigMap = new Map(defaultWidgetConfigs.map((config) => [config.kind, config])); + +interface BoardTarget { + boardId: string; + sectionId: string; + layoutId: string; + columnCount: number; +} + +interface PlacementContext extends BoardTarget { + xOffset: number; + yOffset: number; + rowMaxHeight: number; +} + +const placeWidgetAsync = async ( + db: Database, + ctx: PlacementContext, + kind: WidgetKind, + linkedIntegrationIds: string[], + options?: string, + size?: { width: number; height: number }, +) => { + const itemWidth = size?.width ?? 2; + const itemHeight = size?.height ?? 2; + + if (ctx.xOffset + itemWidth > ctx.columnCount) { + ctx.xOffset = 0; + ctx.yOffset += ctx.rowMaxHeight; + ctx.rowMaxHeight = 0; + } + + const itemId = createId(); + await db.insert(items).values({ + id: itemId, + boardId: ctx.boardId, + kind, + options: options ?? emptySuperJSON, + advancedOptions: emptySuperJSON, + }); + + await db.insert(itemLayouts).values({ + itemId, + sectionId: ctx.sectionId, + layoutId: ctx.layoutId, + xOffset: ctx.xOffset, + yOffset: ctx.yOffset, + width: itemWidth, + height: itemHeight, + }); + + for (const integrationId of linkedIntegrationIds) { + await db.insert(integrationItems).values({ itemId, integrationId }); + } + + ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight); + ctx.xOffset += itemWidth; +}; + +interface App { + id: string; + name: string; +} + +export const placeAllWidgetsAsync = async ( + db: Database, + target: BoardTarget, + allIntegrations: Integration[], + allApps: App[], +) => { + const ctx: PlacementContext = { + ...target, + xOffset: 0, + yOffset: 0, + rowMaxHeight: 0, + }; + + const placedWidgets = new Set(); + + for (const integration of allIntegrations) { + for (const widgetKind of getWidgetKindsForIntegration(integration.kind)) { + if (placedWidgets.has(widgetKind)) continue; + const config = widgetConfigMap.get(widgetKind); + if (config?.skip) continue; + placedWidgets.add(widgetKind); + + const matchingIds = allIntegrations + .filter((row) => getWidgetKindsForIntegration(row.kind).includes(widgetKind)) + .map((row) => row.id); + + const options = config?.options ? superjson.stringify(config.options) : undefined; + await placeWidgetAsync( + db, + ctx, + widgetKind, + matchingIds, + options, + config && { width: config.width, height: config.height }, + ); + } + } + + for (const app of allApps) { + await placeWidgetAsync( + db, + ctx, + "app", + [], + superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }), + { width: 1, height: 1 }, + ); + } + + const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name)); + const bookmarkAppIds = allApps.filter((app) => bookmarkAppNames.has(app.name)).map((app) => app.id); + + for (const config of defaultWidgetConfigs) { + if (config.skip || placedWidgets.has(config.kind)) continue; + placedWidgets.add(config.kind); + + let options = config.options ? { ...config.options } : undefined; + if (config.kind === "bookmarks" && bookmarkAppIds.length > 0) { + options = { ...options, items: bookmarkAppIds }; + } + + await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, { + width: config.width, + height: config.height, + }); + } + + return placedWidgets.size + allApps.length; +}; diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts new file mode 100644 index 000000000..c85715808 --- /dev/null +++ b/packages/definitions/src/docker-integration-match.ts @@ -0,0 +1,90 @@ +import { objectKeys } from "@homarr/common"; + +import { integrationDefs } from "./integration"; +import type { IntegrationKind } from "./integration"; + +export const extractContainerImageName = (image: string): string => image.split("/").at(-1)?.split(":").at(0) ?? ""; + +const extractIconSlug = (iconUrl: string): string => { + const filename = iconUrl.split("/").pop() ?? ""; + return filename.replace(/\.(svg|png)$/, "").toLowerCase(); +}; + +export const integrationIconSlugs: Record = Object.fromEntries( + objectKeys(integrationDefs).map((kind) => [kind, extractIconSlug(integrationDefs[kind].iconUrl)]), +) as Record; + +const integrationAliases: Partial> = { + piHole: ["pihole", "pi-hole"], + adGuardHome: ["adguardhome", "adguard-home", "adguard"], + homeAssistant: ["homeassistant", "home-assistant", "hass"], + openmediavault: ["omv"], + qBittorrent: ["qbittorrent"], + sabNzbd: ["sabnzbd"], + nzbGet: ["nzbget"], + unifiController: ["unifi", "unifi-controller"], + jellyseerr: ["jellyseerr"], + overseerr: ["overseerr"], + dashDot: ["dashdot", "dash-dot", "dash."], + speedtestTracker: ["speedtest-tracker"], + searchCh: ["search-ch"], + gitHubContainerRegistry: ["ghcr"], + linuxServerIO: ["linuxserver"], + coolify: ["coolify"], + truenas: ["truenas"], +}; + +export const matchIntegrationKind = (search: string): IntegrationKind | null => { + const normalized = search.toLowerCase().trim(); + if (!normalized) return null; + + for (const kind of objectKeys(integrationDefs)) { + if (kind.toLowerCase() === normalized) return kind; + if (integrationDefs[kind].name.toLowerCase() === normalized) return kind; + } + + for (const kind of objectKeys(integrationDefs)) { + if (integrationIconSlugs[kind] === normalized) return kind; + } + + for (const [kind, aliases] of Object.entries(integrationAliases) as [IntegrationKind, readonly string[]][]) { + if (aliases.some((alias) => alias === normalized)) return kind; + } + + for (const kind of objectKeys(integrationDefs)) { + if (normalized.includes(kind.toLowerCase())) return kind; + if (normalized.includes(integrationIconSlugs[kind])) return kind; + } + + return null; +}; + +const notDockerDiscoverable = new Set([ + "dockerHub", + "github", + "gitlab", + "npm", + "codeberg", + "linuxServerIO", + "gitHubContainerRegistry", + "quay", + "ical", + "searchCh", + "mock", +]); + +interface ContainerMatchInput { + image: string; + name: string; +} + +export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => { + const imageName = extractContainerImageName(container.image); + const fromImage = matchIntegrationKind(imageName); + if (fromImage && !notDockerDiscoverable.has(fromImage)) return fromImage; + + const fromName = matchIntegrationKind(container.name); + if (fromName && !notDockerDiscoverable.has(fromName)) return fromName; + + return null; +}; diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts index 3eb639ba6..eb81e4d18 100644 --- a/packages/definitions/src/index.ts +++ b/packages/definitions/src/index.ts @@ -12,5 +12,8 @@ export * from "./docs"; export * from "./cookie"; export * from "./search-engine"; export * from "./onboarding"; +export * from "./widget-integration-map"; export * from "./emptysuperjson"; export * from "./hotkeys"; +export * from "./docker-integration-match"; +export * from "./integration-url-template"; diff --git a/packages/definitions/src/integration-url-template.ts b/packages/definitions/src/integration-url-template.ts new file mode 100644 index 000000000..bb8312c6e --- /dev/null +++ b/packages/definitions/src/integration-url-template.ts @@ -0,0 +1,41 @@ +import { removeTrailingSlash } from "@homarr/common"; + +import { integrationIconSlugs } from "./docker-integration-match"; +import type { IntegrationKind } from "./integration"; +import { getIntegrationDefaultPort } from "./integration"; + +export type UrlTemplateMode = "hostPort" | "subdomain"; + +const getSlugForKind = (kind: IntegrationKind): string => integrationIconSlugs[kind]; + +const buildUrl = (slug: string, host: string, mode: UrlTemplateMode, port?: number): string => { + const modeBuilders: Record string> = { + subdomain: () => `https://${slug}.${host}`, + hostPort: () => (port ? `http://${host}:${port}` : `http://${host}`), + }; + return modeBuilders[mode](); +}; + +export const buildIntegrationUrl = ( + kind: IntegrationKind, + baseHost: string, + mode: UrlTemplateMode, + dockerPort?: number, +): string => { + const host = removeTrailingSlash(baseHost.trim()); + if (!host) return ""; + const port = dockerPort ?? getIntegrationDefaultPort(kind); + return buildUrl(getSlugForKind(kind), host, mode, port ?? undefined); +}; + +export const buildAppUrl = ( + containerName: string, + baseHost: string, + mode: UrlTemplateMode, + dockerPort?: number, +): string => { + const host = removeTrailingSlash(baseHost.trim()); + if (!host) return ""; + const slug = containerName.toLowerCase().replace(/[^a-z0-9-]/g, "-"); + return buildUrl(slug, host, mode, dockerPort); +}; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index f90f81a38..a56827f15 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -1,4 +1,4 @@ -import { objectKeys } from "@homarr/common"; +import { objectKeys, removeTrailingSlash } from "@homarr/common"; import type { AtLeastOneOf } from "@homarr/common/types"; import { createDocumentationLink } from "./docs"; @@ -27,7 +27,9 @@ interface integrationDefinition { secretKinds: AtLeastOneOf; // at least one secret kind set is required category: AtLeastOneOf; documentationUrl: string | null; - defaultUrl?: string; // optional default URL for the integration + defaultUrl?: string; + defaultPort?: number; + apiKeySettingsPath?: string; } export const integrationDefs = { @@ -37,6 +39,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg", category: ["downloadClient", "usenet"], documentationUrl: createDocumentationLink("/docs/integrations/sabnzbd"), + defaultPort: 8080, + apiKeySettingsPath: "/config/general/", }, nzbGet: { name: "NZBGet", @@ -44,6 +48,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg", category: ["downloadClient", "usenet"], documentationUrl: createDocumentationLink("/docs/integrations/nzbget"), + defaultPort: 6789, }, deluge: { name: "Deluge", @@ -51,6 +56,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg", category: ["downloadClient", "torrent"], documentationUrl: createDocumentationLink("/docs/integrations/deluge"), + defaultPort: 8112, }, transmission: { name: "Transmission", @@ -58,6 +64,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg", category: ["downloadClient", "torrent"], documentationUrl: createDocumentationLink("/docs/integrations/transmission"), + defaultPort: 9091, }, qBittorrent: { name: "qBittorrent", @@ -65,6 +72,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg", category: ["downloadClient", "torrent"], documentationUrl: createDocumentationLink("/docs/integrations/q-bittorent"), + defaultPort: 8080, + apiKeySettingsPath: "/#/settings/webui", }, aria2: { name: "Aria2", @@ -72,6 +81,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg", category: ["downloadClient", "torrent", "miscellaneous"], documentationUrl: createDocumentationLink("/docs/integrations/aria2"), + defaultPort: 6800, }, slskd: { name: "Slskd", @@ -79,6 +89,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/slskd.svg", category: ["downloadClient", "miscellaneous"], documentationUrl: createDocumentationLink("/docs/integrations/slskd"), + defaultPort: 5030, }, sonarr: { name: "Sonarr", @@ -86,6 +97,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg", category: ["calendar"], documentationUrl: createDocumentationLink("/docs/integrations/sonarr"), + defaultPort: 8989, + apiKeySettingsPath: "/settings/general", }, radarr: { name: "Radarr", @@ -93,6 +106,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg", category: ["calendar"], documentationUrl: createDocumentationLink("/docs/integrations/radarr"), + defaultPort: 7878, + apiKeySettingsPath: "/settings/general", }, lidarr: { name: "Lidarr", @@ -100,6 +115,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg", category: ["calendar"], documentationUrl: createDocumentationLink("/docs/integrations/lidarr"), + defaultPort: 8686, + apiKeySettingsPath: "/settings/general", }, readarr: { name: "Readarr", @@ -107,6 +124,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg", category: ["calendar"], documentationUrl: createDocumentationLink("/docs/integrations/readarr"), + defaultPort: 8787, + apiKeySettingsPath: "/settings/general", }, prowlarr: { name: "Prowlarr", @@ -114,6 +133,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg", category: ["indexerManager"], documentationUrl: createDocumentationLink("/docs/integrations/prowlarr"), + defaultPort: 9696, + apiKeySettingsPath: "/settings/general", }, jellyfin: { name: "Jellyfin", @@ -121,6 +142,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg", category: ["mediaService", "mediaRelease"], documentationUrl: createDocumentationLink("/docs/integrations/jellyfin"), + defaultPort: 8096, + apiKeySettingsPath: "/web/index.html#!/dashboard/keys", }, emby: { name: "Emby", @@ -128,6 +151,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg", category: ["mediaService", "mediaRelease"], documentationUrl: createDocumentationLink("/docs/integrations/emby"), + defaultPort: 8096, }, plex: { name: "Plex", @@ -135,6 +159,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg", category: ["mediaService", "mediaRelease"], documentationUrl: createDocumentationLink("/docs/integrations/plex"), + defaultPort: 32400, }, jellyseerr: { name: "Jellyseerr", @@ -142,6 +167,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg", category: ["mediaSearch", "mediaRequest", "search"], documentationUrl: createDocumentationLink("/docs/integrations/jellyseerr"), + defaultPort: 5055, + apiKeySettingsPath: "/settings", }, seerr: { name: "Seerr", @@ -149,6 +176,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg", category: ["mediaSearch", "mediaRequest", "search"], documentationUrl: createDocumentationLink("/docs/integrations/seerr"), + defaultPort: 5055, + apiKeySettingsPath: "/settings", }, overseerr: { name: "Overseerr", @@ -156,6 +185,8 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg", category: ["mediaSearch", "mediaRequest", "search"], documentationUrl: createDocumentationLink("/docs/integrations/overseerr"), + defaultPort: 5055, + apiKeySettingsPath: "/settings", }, piHole: { name: "Pi-hole", @@ -163,6 +194,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg", category: ["dnsHole"], documentationUrl: createDocumentationLink("/docs/integrations/pi-hole"), + defaultPort: 80, }, adGuardHome: { name: "AdGuard Home", @@ -170,6 +202,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg", category: ["dnsHole"], documentationUrl: createDocumentationLink("/docs/integrations/adguard-home"), + defaultPort: 3000, }, homeAssistant: { name: "Home Assistant", @@ -177,6 +210,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg", category: ["smartHomeServer", "calendar"], documentationUrl: createDocumentationLink("/docs/integrations/home-assistant"), + defaultPort: 8123, }, openmediavault: { name: "OpenMediaVault", @@ -184,6 +218,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/openmediavault.svg", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/open-media-vault"), + defaultPort: 80, }, dashDot: { name: "Dash.", @@ -198,6 +233,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg", category: ["healthMonitoring"], documentationUrl: null, + defaultPort: 61208, }, tdarr: { name: "Tdarr", @@ -205,6 +241,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png", category: ["mediaTranscoding"], documentationUrl: createDocumentationLink("/docs/integrations/tdarr"), + defaultPort: 8265, }, proxmox: { name: "Proxmox", @@ -212,6 +249,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/proxmox"), + defaultPort: 8006, }, nextcloud: { name: "Nextcloud", @@ -219,6 +257,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg", category: ["calendar", "notifications"], documentationUrl: createDocumentationLink("/docs/integrations/nextcloud"), + defaultPort: 443, }, unifiController: { name: "Unifi Controller", @@ -226,6 +265,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png", category: ["networkController"], documentationUrl: createDocumentationLink("/docs/integrations/unifi-controller"), + defaultPort: 8443, }, opnsense: { name: "OPNsense", @@ -233,6 +273,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg", category: ["firewall"], documentationUrl: createDocumentationLink("/docs/integrations/opnsense"), + defaultPort: 443, }, github: { name: "Github", @@ -304,6 +345,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg", category: ["notifications"], documentationUrl: createDocumentationLink("/docs/integrations/ntfy"), + defaultPort: 80, }, ical: { name: "iCal", @@ -318,6 +360,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg", category: ["notes"], documentationUrl: createDocumentationLink("/docs/integrations/anchor"), + defaultPort: 8080, }, truenas: { name: "TrueNAS", @@ -325,6 +368,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/truenas"), + defaultPort: 80, }, unraid: { name: "Unraid", @@ -332,6 +376,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/unraid"), + defaultPort: 80, }, coolify: { name: "Coolify", @@ -340,12 +385,12 @@ export const integrationDefs = { category: ["healthMonitoring"], // @ts-expect-error - docs page will be created when integration is merged documentationUrl: createDocumentationLink("/docs/integrations/coolify"), + defaultPort: 8000, }, searchCh: { name: "Search.ch", secretKinds: [[]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png", - defaultUrl: "https://search.ch", category: ["timetable"], documentationUrl: createDocumentationLink("/docs/integrations/search-ch"), }, @@ -355,6 +400,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg", category: ["photoService"], documentationUrl: createDocumentationLink("/docs/integrations/immich"), + defaultPort: 2283, }, tracearr: { name: "Tracearr", @@ -362,6 +408,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg", category: ["mediaMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/tracearr"), + defaultPort: 7040, }, speedtestTracker: { name: "Speedtest Tracker", @@ -369,6 +416,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png", category: ["speedtest"], documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"), + defaultPort: 80, }, umami: { name: "Umami", @@ -377,6 +425,7 @@ export const integrationDefs = { category: ["analytics"], defaultUrl: "https://api.umami.is/v1", documentationUrl: createDocumentationLink("/docs/integrations/umami"), + defaultPort: 3000, }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) mock: { @@ -418,6 +467,19 @@ export const getIntegrationDefaultUrl = (integration: IntegrationKind) => { return "defaultUrl" in definition ? definition.defaultUrl : undefined; }; +export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undefined => { + const definition = integrationDefs[kind]; + return "defaultPort" in definition ? definition.defaultPort : undefined; +}; + +export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => { + const definition = integrationDefs[kind]; + if (!("apiKeySettingsPath" in definition)) return null; + const base = removeTrailingSlash(integrationUrl); + if (!base) return null; + return `${base}${definition.apiKeySettingsPath}`; +}; + /** * Get all integration kinds that share a category, typed only by the kinds belonging to the category * @param category Category to filter by, belonging to IntegrationCategory diff --git a/packages/definitions/src/onboarding.ts b/packages/definitions/src/onboarding.ts index d9a6ea03e..305a71f65 100644 --- a/packages/definitions/src/onboarding.ts +++ b/packages/definitions/src/onboarding.ts @@ -1,2 +1,2 @@ -export const onboardingSteps = ["start", "import", "user", "group", "settings", "finish"] as const; +export const onboardingSteps = ["start", "import", "user", "group", "settings", "integrations", "finish"] as const; export type OnboardingStep = (typeof onboardingSteps)[number]; diff --git a/packages/definitions/src/test/api-key-url.spec.ts b/packages/definitions/src/test/api-key-url.spec.ts new file mode 100644 index 000000000..21b7e0fe7 --- /dev/null +++ b/packages/definitions/src/test/api-key-url.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { getIntegrationApiKeyUrl } from "../integration"; + +describe("getIntegrationApiKeyUrl", () => { + it("builds URL for sonarr", () => { + expect(getIntegrationApiKeyUrl("http://192.168.1.10:8989", "sonarr")).toBe( + "http://192.168.1.10:8989/settings/general", + ); + }); + + it("builds URL for jellyfin", () => { + expect(getIntegrationApiKeyUrl("http://media.local:8096", "jellyfin")).toBe( + "http://media.local:8096/web/index.html#!/dashboard/keys", + ); + }); + + it("returns null for integrations without apiKeySettingsPath", () => { + expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull(); + }); + + it("returns null for empty URL", () => { + expect(getIntegrationApiKeyUrl("", "sonarr")).toBeNull(); + }); + + it("strips trailing slash from base URL", () => { + expect(getIntegrationApiKeyUrl("http://sonarr.local/", "sonarr")).toBe("http://sonarr.local/settings/general"); + }); +}); diff --git a/packages/definitions/src/test/docker-integration-match.spec.ts b/packages/definitions/src/test/docker-integration-match.spec.ts new file mode 100644 index 000000000..2a5db3a14 --- /dev/null +++ b/packages/definitions/src/test/docker-integration-match.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { + extractContainerImageName, + matchIntegrationKind, + matchIntegrationKindFromContainer, +} from "../docker-integration-match"; + +describe("extractContainerImageName", () => { + it("extracts name from full image reference", () => { + expect(extractContainerImageName("linuxserver/sonarr:latest")).toBe("sonarr"); + }); + + it("extracts name from ghcr image", () => { + expect(extractContainerImageName("ghcr.io/org/radarr:v5")).toBe("radarr"); + }); + + it("handles plain image name", () => { + expect(extractContainerImageName("jellyfin")).toBe("jellyfin"); + }); + + it("handles empty string", () => { + expect(extractContainerImageName("")).toBe(""); + }); +}); + +describe("matchIntegrationKind", () => { + it("matches exact kind name", () => { + expect(matchIntegrationKind("sonarr")).toBe("sonarr"); + }); + + it("matches case-insensitively", () => { + expect(matchIntegrationKind("Sonarr")).toBe("sonarr"); + }); + + it("matches icon slug for pi-hole", () => { + expect(matchIntegrationKind("pi-hole")).toBe("piHole"); + }); + + it("matches alias pihole", () => { + expect(matchIntegrationKind("pihole")).toBe("piHole"); + }); + + it("matches alias adguardhome", () => { + expect(matchIntegrationKind("adguardhome")).toBe("adGuardHome"); + }); + + it("matches alias homeassistant", () => { + expect(matchIntegrationKind("homeassistant")).toBe("homeAssistant"); + }); + + it("matches substring in image name", () => { + expect(matchIntegrationKind("hotio-sonarr")).toBe("sonarr"); + }); + + it("returns null for unknown", () => { + expect(matchIntegrationKind("nginx")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(matchIntegrationKind("")).toBeNull(); + }); +}); + +describe("matchIntegrationKindFromContainer", () => { + it("matches from image name first", () => { + expect( + matchIntegrationKindFromContainer({ + image: "linuxserver/sonarr:latest", + name: "my-sonarr", + }), + ).toBe("sonarr"); + }); + + it("falls back to container name", () => { + expect( + matchIntegrationKindFromContainer({ + image: "custom/myapp:latest", + name: "radarr", + }), + ).toBe("radarr"); + }); + + it("returns null when neither matches", () => { + expect( + matchIntegrationKindFromContainer({ + image: "nginx:latest", + name: "web-proxy", + }), + ).toBeNull(); + }); + + it("excludes dockerHub even when image contains docker", () => { + expect( + matchIntegrationKindFromContainer({ + image: "portainer/portainer-ce:latest", + name: "docker-proxy", + }), + ).toBeNull(); + }); + + it("excludes ical from container matching", () => { + expect( + matchIntegrationKindFromContainer({ + image: "ical-relay:latest", + name: "ical", + }), + ).toBeNull(); + }); + + it("excludes github from container matching", () => { + expect( + matchIntegrationKindFromContainer({ + image: "github-runner:latest", + name: "github-actions", + }), + ).toBeNull(); + }); +}); diff --git a/packages/definitions/src/test/integration-url-template.spec.ts b/packages/definitions/src/test/integration-url-template.spec.ts new file mode 100644 index 000000000..05e7a4e9b --- /dev/null +++ b/packages/definitions/src/test/integration-url-template.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { getIntegrationDefaultPort } from "../integration"; +import { buildIntegrationUrl } from "../integration-url-template"; + +describe("buildIntegrationUrl", () => { + it("builds subdomain URL", () => { + expect(buildIntegrationUrl("sonarr", "homelab.local", "subdomain")).toBe("https://sonarr.homelab.local"); + }); + + it("builds hostPort URL with default port", () => { + expect(buildIntegrationUrl("sonarr", "192.168.1.1", "hostPort")).toBe("http://192.168.1.1:8989"); + }); + + it("uses docker port over default port", () => { + expect(buildIntegrationUrl("sonarr", "192.168.1.1", "hostPort", 9999)).toBe("http://192.168.1.1:9999"); + }); + + it("builds hostPort URL without port when none known", () => { + expect(buildIntegrationUrl("mock", "192.168.1.1", "hostPort")).toBe("http://192.168.1.1"); + }); + + it("strips trailing slashes from host", () => { + expect(buildIntegrationUrl("sonarr", "homelab.local/", "subdomain")).toBe("https://sonarr.homelab.local"); + expect(buildIntegrationUrl("sonarr", "homelab.local///", "subdomain")).toBe("https://sonarr.homelab.local"); + }); + + it("returns empty string for empty host", () => { + expect(buildIntegrationUrl("sonarr", "", "subdomain")).toBe(""); + }); + + it("uses pi-hole slug for subdomain mode", () => { + expect(buildIntegrationUrl("piHole", "example.com", "subdomain")).toBe("https://pi-hole.example.com"); + }); + + it("reads default ports from integration definitions", () => { + expect(getIntegrationDefaultPort("radarr")).toBe(7878); + expect(getIntegrationDefaultPort("prowlarr")).toBe(9696); + expect(getIntegrationDefaultPort("jellyfin")).toBe(8096); + }); +}); diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts new file mode 100644 index 000000000..bef83fea6 --- /dev/null +++ b/packages/definitions/src/widget-integration-map.ts @@ -0,0 +1,113 @@ +import { createDocumentationLink } from "./docs"; +import type { IntegrationKind } from "./integration"; +import { getIntegrationKindsByCategory } from "./integration"; +import type { WidgetKind } from "./widget"; + +/** + * Static mapping of widget kinds to the integration kinds they support. + * Mirrors the supportedIntegrations declared in each widget definition + * but lives in @homarr/definitions so server-side code (API layer) + * can use it without importing @homarr/widgets. + */ +export const widgetIntegrationSupport: Partial> = { + dnsHoleSummary: getIntegrationKindsByCategory("dnsHole"), + dnsHoleControls: getIntegrationKindsByCategory("dnsHole"), + "smartHome-entityState": getIntegrationKindsByCategory("smartHomeServer"), + "smartHome-executeAutomation": getIntegrationKindsByCategory("smartHomeServer"), + mediaServer: getIntegrationKindsByCategory("mediaService"), + calendar: getIntegrationKindsByCategory("calendar"), + downloads: getIntegrationKindsByCategory("downloadClient"), + "mediaRequests-requestList": getIntegrationKindsByCategory("mediaRequest"), + "mediaRequests-requestStats": getIntegrationKindsByCategory("mediaRequest"), + mediaTranscoding: getIntegrationKindsByCategory("mediaTranscoding"), + networkControllerSummary: getIntegrationKindsByCategory("networkController"), + networkControllerStatus: getIntegrationKindsByCategory("networkController"), + indexerManager: getIntegrationKindsByCategory("indexerManager"), + healthMonitoring: getIntegrationKindsByCategory("healthMonitoring"), + firewall: getIntegrationKindsByCategory("firewall"), + notifications: getIntegrationKindsByCategory("notifications"), + mediaReleases: ["emby", "jellyfin", "plex"], + systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances"], + systemDisks: ["dashDot", "openmediavault", "truenas", "unraid"], + coolify: ["coolify"], + timetable: ["searchCh"], + "immich-serverStats": ["immich"], + "immich-albumCarousel": ["immich"], + tracearr: ["tracearr"], + speedtestTracker: ["speedtestTracker"], + anchorNote: ["anchor"], +}; + +export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => { + const result: WidgetKind[] = []; + for (const [widgetKind, supportedIntegrations] of Object.entries(widgetIntegrationSupport)) { + if (supportedIntegrations.includes(integrationKind)) { + result.push(widgetKind as WidgetKind); + } + } + return result; +}; + +export interface DefaultWidgetConfig { + kind: WidgetKind; + width: number; + height: number; + options?: Record; + skip?: boolean; +} + +export const defaultWidgetConfigs: DefaultWidgetConfig[] = [ + { kind: "clock", width: 2, height: 1 }, + { kind: "weather", width: 2, height: 1, options: { showCity: true, hasForecast: true, forecastDayCount: 3 } }, + { kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } }, +]; + +export const generalWidgets: WidgetKind[] = defaultWidgetConfigs + .filter((config) => !config.skip) + .map((config) => config.kind); + +export const featuredIntegrations: readonly IntegrationKind[] = [ + "sonarr", + "radarr", + "prowlarr", + "sabNzbd", + "qBittorrent", + "seerr", + "jellyfin", +]; + +export const hiddenFromOnboarding = new Set([ + "searchCh", + "github", + "dockerHub", + "gitlab", + "npm", + "codeberg", + "linuxServerIO", + "gitHubContainerRegistry", + "quay", + "mock", +]); + +export const defaultBookmarkApps = [ + { + name: "Homarr Docs", + href: createDocumentationLink("/docs/getting-started"), + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", + }, + { + name: "Homarr GitHub", + href: "https://github.com/homarr-labs/homarr", + iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/github.svg", + }, + { + name: "Help Translate", + href: createDocumentationLink("/docs/community/translations"), + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg", + }, + { + name: "Support Homarr", + href: "https://opencollective.com/homarr", + iconUrl: "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets@latest/assets/opencollective.svg", + }, +]; diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx index e5530d341..6ac726ac0 100644 --- a/packages/modals-collection/src/apps/app-select-modal.tsx +++ b/packages/modals-collection/src/apps/app-select-modal.tsx @@ -1,11 +1,12 @@ import { useMemo, useState } from "react"; -import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core"; -import { IconPlus, IconSearch } from "@tabler/icons-react"; +import { Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { IconPlus } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { createModal, useModalAction } from "@homarr/modals"; +import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal"; @@ -29,96 +30,119 @@ export const AppSelectModal = createModal(({ actions, inner ); const handleSelect = (app: RouterOutputs["app"]["selectable"][number]) => { - if (innerProps.onSelect) { - innerProps.onSelect(app); - } + innerProps.onSelect?.(app); actions.closeModal(); }; const handleAddNewApp = () => { openQuickAddAppModal({ onClose(app) { - if (innerProps.onSelect) { - innerProps.onSelect(app); - } + innerProps.onSelect?.(app); actions.closeModal(); }, }); }; return ( - - setSearch(event.currentTarget.value)} - leftSection={} - placeholder={`${t("app.action.select.search")}...`} - data-autofocus - onKeyDown={(event) => { - if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) { - handleSelect(filteredApps[0]); - } - }} - /> + { + if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) { + handleSelect(filteredApps[0]); + } + }} + > + {innerProps.withCreate && ( + e.currentTarget.style.setProperty("--_hover-opacity", "1")} + onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + > + + + + + {t("app.action.create.title")} + + + + {t("app.action.create.description")} + + + + + + + )} - - {innerProps.withCreate && ( - - - - -
- -
- - {t("app.action.create.title")} - - - {t("app.action.create.description")} - -
- -
-
-
- )} + {filteredApps.map((app) => ( + e.currentTarget.style.setProperty("--_hover-opacity", "1")} + onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + > + + + {app.name} + + {app.name} + + + + + {app.description ?? ""} + + + + + + + + ))} - {filteredApps.map((app) => ( - - - - -
- {app.name} -
- - {app.name} - - - {app.description ?? ""} - -
- -
-
-
- ))} - - {filteredApps.length === 0 && !isPending && ( - -
- {t("app.action.select.noResults")} -
-
- )} -
-
+ {filteredApps.length === 0 && !isPending && ( +
+ {t("app.action.select.noResults")} +
+ )} + ); }).withOptions({ defaultTitle: (t) => t("app.action.select.title"), - size: "xl", + size: modalSizeSelect, }); diff --git a/packages/modals-collection/src/apps/quick-add-app/quick-add-app-modal.tsx b/packages/modals-collection/src/apps/quick-add-app/quick-add-app-modal.tsx index 617bf6403..04e9f6f8b 100644 --- a/packages/modals-collection/src/apps/quick-add-app/quick-add-app-modal.tsx +++ b/packages/modals-collection/src/apps/quick-add-app/quick-add-app-modal.tsx @@ -3,7 +3,7 @@ import type { z } from "zod/v4"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { AppForm } from "@homarr/forms-collection"; -import { createModal } from "@homarr/modals"; +import { createModal, modalSizeForm } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { appManageSchema } from "@homarr/validation/app"; @@ -54,4 +54,5 @@ export const QuickAddAppModal = createModal(({ actions, i defaultTitle(t) { return t("board.action.quickCreateApp.modal.title"); }, + size: modalSizeForm, }); diff --git a/packages/modals/index.ts b/packages/modals/index.ts index f7a07beae..196451a8e 100644 --- a/packages/modals/index.ts +++ b/packages/modals/index.ts @@ -1,2 +1,4 @@ export { ModalProvider, useModalAction, useConfirmModal } from "./src"; export { createModal } from "./src/creator"; +export { ModalFormFooter } from "./src/modal-form-footer"; +export { modalSizeForm, modalSizeSelect } from "./src/modal-sizes"; diff --git a/packages/modals/src/modal-form-footer.tsx b/packages/modals/src/modal-form-footer.tsx new file mode 100644 index 000000000..f274b2dd4 --- /dev/null +++ b/packages/modals/src/modal-form-footer.tsx @@ -0,0 +1,53 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Button, Group } from "@mantine/core"; + +import { useI18n } from "@homarr/translation/client"; + +interface ModalFormFooterProps { + onCancel: () => void; + submitLabel?: string; + cancelLabel?: string; + loading?: boolean; + leftSection?: ReactNode; +} + +const ActionButtons = ({ + onCancel, + submitLabel, + cancelLabel, + loading, +}: Pick) => { + const t = useI18n(); + + return ( + <> + + + + ); +}; + +export const ModalFormFooter = ({ onCancel, submitLabel, cancelLabel, loading, leftSection }: ModalFormFooterProps) => { + const actionButtons = ( + + ); + + if (leftSection) { + return ( + + {leftSection} + + {actionButtons} + + + ); + } + + return {actionButtons}; +}; diff --git a/packages/modals/src/modal-sizes.ts b/packages/modals/src/modal-sizes.ts new file mode 100644 index 000000000..cf631943c --- /dev/null +++ b/packages/modals/src/modal-sizes.ts @@ -0,0 +1 @@ +export { modalSizeForm, modalSizeSelect } from "@homarr/ui"; diff --git a/packages/old-import/src/components/initial-oldmarr-import.tsx b/packages/old-import/src/components/initial-oldmarr-import.tsx index a87a8f651..92aa301a5 100644 --- a/packages/old-import/src/components/initial-oldmarr-import.tsx +++ b/packages/old-import/src/components/initial-oldmarr-import.tsx @@ -69,7 +69,7 @@ export const InitialOldmarrImport = ({ file, analyseResult }: InitialOldmarrImpo }; return ( - + { diff --git a/packages/old-import/src/components/initial/board-selection-card.tsx b/packages/old-import/src/components/initial/board-selection-card.tsx index 4665ec2cd..6f178f669 100644 --- a/packages/old-import/src/components/initial/board-selection-card.tsx +++ b/packages/old-import/src/components/initial/board-selection-card.tsx @@ -38,7 +38,7 @@ export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelect } return ( - + diff --git a/packages/old-import/src/components/initial/import-settings-card.tsx b/packages/old-import/src/components/initial/import-settings-card.tsx index 1a2809d76..f230ff921 100644 --- a/packages/old-import/src/components/initial/import-settings-card.tsx +++ b/packages/old-import/src/components/initial/import-settings-card.tsx @@ -17,7 +17,7 @@ interface ImportSettingsCardProps { export const ImportSettingsCard = ({ settings, updateSetting }: ImportSettingsCardProps) => { const tImportSettings = useScopedI18n("init.step.import.importSettings"); return ( - + {tImportSettings("title")} diff --git a/packages/old-import/src/components/initial/import-summary-card.tsx b/packages/old-import/src/components/initial/import-summary-card.tsx index 9993b3ac2..1000b35a6 100644 --- a/packages/old-import/src/components/initial/import-summary-card.tsx +++ b/packages/old-import/src/components/initial/import-summary-card.tsx @@ -13,7 +13,7 @@ interface ImportSummaryCardProps { export const ImportSummaryCard = ({ counts, onSubmit, loading }: ImportSummaryCardProps) => { const tSummary = useScopedI18n("init.step.import.summary"); return ( - + {tSummary("title")} diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index d2b24466c..94633a30a 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -7,6 +7,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { db, like, or } from "@homarr/db"; import { icons } from "@homarr/db/schema"; +import { extractContainerImageName } from "@homarr/definitions"; import type { ContainerState, Port } from "@homarr/docker"; import { dockerLabels, DockerSingleton } from "@homarr/docker"; @@ -180,7 +181,7 @@ export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({ cacheDuration: dayjs.duration(20, "seconds"), }); -const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? ""; +const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image); async function getContainersWithStatsAsync() { const dockerInstances = DockerSingleton.getInstances(); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 5b7842723..1fe82d169 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -90,6 +90,48 @@ "title": "Settings", "subtitle": "Configure server settings." }, + "integrations": { + "title": "Integrations", + "subtitle": "Select the services you use.", + "description": "Choose the integrations you'd like to connect. We'll set up your dashboard with matching widgets automatically.", + "action": { + "continue": "Continue", + "skip": "Skip for now" + }, + "configure": { + "progress": "{current} of {total}", + "skip": "Skip this integration", + "finishing": "Setting up your dashboard...", + "error": { + "title": "Setup failed", + "message": "Something went wrong while setting up your dashboard. Please try again." + }, + "helpers": { + "tooltip": "URL helpers", + "description": "These helpers try to guess the URL for your services. If it doesn't work, just type the address manually." + } + }, + "urlTemplate": { + "label": "Base URL", + "placeholder": "homelab.local or 192.168.1.1", + "hint": "Enter your base domain or IP to auto-fill integration URLs", + "warning": "Setting your base URL now will auto-fill all integration URLs, saving you time during setup", + "preview": "e.g. {example}", + "mode": { + "subdomain": "Subdomain", + "hostPort": "Host:Port" + } + }, + "docker": { + "detected": "Docker detected {count} services", + "detectedBreakdown": "{integrations} integrations · {apps} apps", + "summary": "Homarr detected {apps} apps, {integrations} of which have integrations built-in", + "appBadge": "App", + "appSectionLabel": "Apps to add to dashboard ({count} selected)", + "scanning": "Scanning Docker...", + "scanningDescription": "Looking for running containers on your system" + } + }, "finish": { "title": "Finish setup", "subtitle": "You are ready to go!", @@ -731,11 +773,46 @@ }, "appHref": { "placeholder": "Custom app URL" + }, + "apiKeySettings": { + "label": "Get API key" } }, "action": { "create": "New integration" }, + "grid": { + "widgetCount": "{count} widgets", + "noWidgets": "No widgets", + "detected": "Detected via Docker" + }, + "category": { + "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", + "analytics": "Analytics" + }, "testConnection": { "action": { "create": "Test connection and create", @@ -1068,6 +1145,7 @@ "add": "Add", "apply": "Apply", "backToOverview": "Back to overview", + "skip": "Skip", "change": "Change", "create": "Create", "createAnother": "Create and start over", diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index 717359dac..9bc663a46 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -16,3 +16,9 @@ export { MaskedImage } from "./masked-image"; export { MaskedOrNormalImage } from "./masked-or-normal-image"; export { LanguageIcon } from "./language-icon"; export { Link } from "./link"; +export { + SelectGridLayout, + selectGridCardHeight, + selectGridCols, + selectGridScrollMaxHeight, +} from "../select-grid-layout"; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 364f7a394..1e8db7d58 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -3,6 +3,7 @@ import type { MantineProviderProps } from "@mantine/core"; import { theme } from "./theme"; export { theme } from "./theme"; +export { modalSizeForm, modalSizeSelect } from "./theme/modal"; export * from "./components"; export const uiConfiguration = { diff --git a/packages/ui/src/select-grid-layout.tsx b/packages/ui/src/select-grid-layout.tsx new file mode 100644 index 000000000..10f48f3df --- /dev/null +++ b/packages/ui/src/select-grid-layout.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { KeyboardEvent, ReactNode } from "react"; +import { Input, ScrollArea, SimpleGrid, Stack } from "@mantine/core"; +import { IconSearch } from "@tabler/icons-react"; + +export const selectGridCols = { base: 2, xs: 3, sm: 4, md: 5 }; +export const selectGridCardHeight = 140; +export const selectGridScrollMaxHeight = "70vh"; + +interface SelectGridLayoutProps { + search: string; + onSearchChange: (value: string) => void; + placeholder: string; + onSearchKeyDown?: (event: KeyboardEvent) => void; + disableScroll?: boolean; + disableAutoFocus?: boolean; + children: ReactNode; +} + +export const SelectGridLayout = ({ + search, + onSearchChange, + placeholder, + onSearchKeyDown, + disableScroll = false, + disableAutoFocus = false, + children, +}: SelectGridLayoutProps) => { + const grid = ( + + {children} + + ); + + return ( + + onSearchChange(event.currentTarget.value)} + leftSection={} + placeholder={placeholder} + data-autofocus={disableAutoFocus ? undefined : true} + onKeyDown={onSearchKeyDown} + /> + + {disableScroll ? grid : {grid}} + + ); +}; diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts index 9aea93dbf..2afa0f689 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -1,5 +1,7 @@ import { createTheme, rem } from "@mantine/core"; +import { modalComponent } from "./theme/modal"; + export const theme = createTheme({ primaryColor: "red", autoContrast: true, @@ -99,5 +101,6 @@ export const theme = createTheme({ overlayProps: { radius: "sm", blur: 2 }, }, }, + Modal: modalComponent, }, }); diff --git a/packages/ui/src/theme/modal.ts b/packages/ui/src/theme/modal.ts new file mode 100644 index 000000000..7bb9f4e69 --- /dev/null +++ b/packages/ui/src/theme/modal.ts @@ -0,0 +1,33 @@ +import type { MantineTheme, MantineThemeComponent, ModalProps } from "@mantine/core"; + +export const modalSizeSelect = "xxl"; +export const modalSizeForm = "lg"; + +const fixedModalSizes = new Set([modalSizeSelect, modalSizeForm]); + +const modalWidth = "min(var(--modal-size), calc(100vw - 2 * var(--mantine-spacing-md)))"; + +export const modalComponent: MantineThemeComponent = { + vars: (_theme: MantineTheme, props: Partial) => { + if (props.size === modalSizeSelect) { + return { root: { "--modal-size": "75rem" } }; + } + return { root: {} }; + }, + styles: (_theme: MantineTheme, props: Partial) => { + const isFixedSize = typeof props.size === "string" && fixedModalSizes.has(props.size); + if (!isFixedSize) { + return {}; + } + + return { + content: { + width: modalWidth, + minWidth: modalWidth, + }, + body: { + width: "100%", + }, + }; + }, +}; diff --git a/packages/widgets/src/bookmarks/app-select-modal.tsx b/packages/widgets/src/bookmarks/app-select-modal.tsx index b99751751..51b26aa8b 100644 --- a/packages/widgets/src/bookmarks/app-select-modal.tsx +++ b/packages/widgets/src/bookmarks/app-select-modal.tsx @@ -2,13 +2,13 @@ import { memo, useState } from "react"; import type { SelectProps } from "@mantine/core"; -import { Button, Group, Loader, Select, Stack } from "@mantine/core"; +import { Group, Loader, Select, Stack } from "@mantine/core"; import { IconCheck } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; +import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; interface InnerProps { @@ -47,6 +47,7 @@ export const AppSelectModal = createModal(({ actions, innerProps }) label={t("app.action.select.label")} searchable clearable + data-autofocus leftSection={} nothingFoundMessage={t("app.action.select.notFound")} renderOption={renderSelectOption} @@ -61,19 +62,13 @@ export const AppSelectModal = createModal(({ actions, innerProps }) })) ?? [] } /> - - - - + ); }).withOptions({ defaultTitle: (t) => t("app.action.select.label"), + size: modalSizeForm, }); const iconProps = { diff --git a/packages/widgets/src/modals/widget-advanced-options-modal.tsx b/packages/widgets/src/modals/widget-advanced-options-modal.tsx index dc785d186..5a1021bd2 100644 --- a/packages/widgets/src/modals/widget-advanced-options-modal.tsx +++ b/packages/widgets/src/modals/widget-advanced-options-modal.tsx @@ -1,9 +1,9 @@ "use client"; -import { Button, CloseButton, ColorInput, Group, Input, Stack, TextInput, useMantineTheme } from "@mantine/core"; +import { CloseButton, ColorInput, Input, Stack, TextInput, useMantineTheme } from "@mantine/core"; import { useForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; +import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; import { TextMultiSelect } from "@homarr/ui"; import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; @@ -34,6 +34,7 @@ export const WidgetAdvancedOptionsModal = createModal(({ actions, in form.setFieldValue("title", "")} />} /> @@ -53,12 +54,7 @@ export const WidgetAdvancedOptionsModal = createModal(({ actions, in } {...form.getInputProps("borderColor")} /> - - - - + ); @@ -66,7 +62,7 @@ export const WidgetAdvancedOptionsModal = createModal(({ actions, in defaultTitle(t) { return t("item.edit.advancedOptions.title"); }, - size: "lg", + size: modalSizeForm, transitionProps: { duration: 0, }, diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 5f837234a..1c2f39700 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -9,7 +9,7 @@ import { z } from "zod/v4"; import { objectEntries } from "@homarr/common"; import { useSession } from "@homarr/auth/client"; import type { WidgetKind } from "@homarr/definitions"; -import { createModal, useModalAction } from "@homarr/modals"; +import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals"; import type { SettingsContextProps } from "@homarr/settings/creator"; import { useI18n } from "@homarr/translation/client"; import { zodErrorMap } from "@homarr/validation/form/i18n"; @@ -145,9 +145,10 @@ export const WidgetEditModal = createModal>(({ actions, i /> ); })} - + {showAppTab ? ( - {!showAppTab && ( - - - - - )} - + } + /> + )} ); @@ -214,5 +229,5 @@ export const WidgetEditModal = createModal>(({ actions, i defaultTitle(t) { return t("item.edit.title"); }, - size: "lg", + size: modalSizeForm, });