From bba80add602a131c769ada1c27f6f5eabbd8cf93 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 1 May 2026 09:58:28 +0200 Subject: [PATCH 01/53] feat(definitions): add widget-integration map and default configs Add static mapping of widget kinds to integration kinds for server-side use without importing @homarr/widgets. Includes default widget configs (size, options, skip), featured integrations list for grid sorting, and default bookmark apps for onboarding dashboard population. --- packages/definitions/src/index.ts | 1 + .../definitions/src/widget-integration-map.ts | 96 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 packages/definitions/src/widget-integration-map.ts diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts index 3eb639ba6..56293b15b 100644 --- a/packages/definitions/src/index.ts +++ b/packages/definitions/src/index.ts @@ -12,5 +12,6 @@ export * from "./docs"; export * from "./cookie"; export * from "./search-engine"; export * from "./onboarding"; +export * from "./widget-integration-map"; export * from "./emptysuperjson"; export * from "./hotkeys"; diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts new file mode 100644 index 000000000..a85487eab --- /dev/null +++ b/packages/definitions/src/widget-integration-map.ts @@ -0,0 +1,96 @@ +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: 1, options: { title: "Useful Links", layout: "grid", openNewTab: true } }, + { kind: "timetable", width: 2, height: 2, skip: true }, +]; + +export const generalWidgets: WidgetKind[] = defaultWidgetConfigs + .filter((config) => !config.skip) + .map((config) => config.kind); + +export const featuredIntegrations: readonly IntegrationKind[] = [ + "sonarr", + "radarr", + "prowlarr", + "sabNzbd", + "qBittorrent", + "jellyseerr", + "jellyfin", +]; + +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", + }, +]; From b6615fbce23820188ed3ee2aa4d0d188b2407c5b Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 1 May 2026 09:58:34 +0200 Subject: [PATCH 02/53] feat(db): seed default board, home board, and bookmark apps Create a default "dashboard" board during seeding with a section and layout. Set it as the home board for the everyone group. Seed default bookmark apps (Homarr Docs, GitHub, Help Translate) for the bookmarks widget. --- packages/db/migrations/seed.ts | 63 +++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 407c03746..3b691a832 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -1,6 +1,7 @@ import { createId, objectKeys } from "@homarr/common"; import { createDocumentationLink, + defaultBookmarkApps, everyoneGroup, getIntegrationDefaultUrl, getIntegrationName, @@ -15,7 +16,7 @@ import { insertServerSettingByKeyAsync, updateServerSettingByKeyAsync, } from "../queries/server-setting"; -import { groups, integrations, onboarding, searchEngines } from "../schema"; +import { apps, boards, groups, integrations, layouts, onboarding, searchEngines, sections } from "../schema"; import type { Integration } from "../schema"; export const seedDataAsync = async (db: Database) => { @@ -24,6 +25,8 @@ export const seedDataAsync = async (db: Database) => { await seedServerSettingsAsync(db); await seedDefaultSearchEnginesAsync(db); await seedDefaultIntegrationsAsync(db); + await seedDefaultAppsAsync(db); + await seedDefaultBoardAsync(db); }; const seedEveryoneGroupAsync = async (db: Database) => { @@ -189,3 +192,61 @@ 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"); +}; From 9e3b4955b92306a1707b2a9a08fe4010a6a63a01 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 1 May 2026 09:58:41 +0200 Subject: [PATCH 03/53] feat(api): add integrations onboarding step with dashboard population Add "integrations" step to onboarding flow between settings and finish. Add setupIntegrations mutation that reads existing integrations from DB, populates the dashboard with matching widgets (respecting config-driven sizes/options/skip), places app widgets for each app, and creates a bookmarks widget with seeded default apps. --- .../api/src/router/onboard/onboard-queries.ts | 3 + .../api/src/router/onboard/onboard-router.ts | 154 +++++++++++++++++- packages/definitions/src/onboarding.ts | 2 +- 3 files changed, 154 insertions(+), 5 deletions(-) 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 [config.kind, config])); + +const getWidgetConfig = (kind: WidgetKind) => widgetConfigMap.get(kind); + export const onboardRouter = createTRPCRouter({ currentStep: publicProcedure.query(async ({ ctx }) => { return await getOnboardingOrFallbackAsync(ctx.db); @@ -14,7 +29,6 @@ export const onboardRouter = createTRPCRouter({ nextStep: publicProcedure .input( z.object({ - // Preferred step is only needed for 'preferred' conditions preferredStep: zodEnumFromArray(onboardingSteps).optional(), }), ) @@ -33,4 +47,136 @@ export const onboardRouter = createTRPCRouter({ step: "start", }); }), + 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) { + 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 columnCount = layout.columnCount; + let xOffset = 0; + let yOffset = 0; + let rowMaxHeight = 0; + + const placeWidgetAsync = async ( + kind: WidgetKind, + linkedIntegrationIds: string[], + options?: string, + size?: { width: number; height: number }, + ) => { + const itemWidth = size?.width ?? 2; + const itemHeight = size?.height ?? 2; + + if (xOffset + itemWidth > columnCount) { + xOffset = 0; + yOffset += rowMaxHeight; + rowMaxHeight = 0; + } + + const itemId = createId(); + await db.insert(items).values({ + id: itemId, + boardId: board.id, + kind, + options: options ?? emptySuperJSON, + advancedOptions: emptySuperJSON, + }); + + await db.insert(itemLayouts).values({ + itemId, + sectionId: section.id, + layoutId: layout.id, + xOffset, + yOffset, + width: itemWidth, + height: itemHeight, + }); + + for (const integrationId of linkedIntegrationIds) { + await db.insert(integrationItems).values({ + itemId, + integrationId, + }); + } + + rowMaxHeight = Math.max(rowMaxHeight, itemHeight); + xOffset += itemWidth; + }; + + const placedWidgets = new Set(); + + // Place integration-specific widgets + for (const integration of allIntegrations) { + const kind = integration.kind; + + for (const widgetKind of getWidgetKindsForIntegration(kind)) { + if (placedWidgets.has(widgetKind)) continue; + const config = getWidgetConfig(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( + widgetKind, + matchingIds, + options, + config && { width: config.width, height: config.height }, + ); + } + } + + // Place an app widget for each app in the DB + for (const app of allApps) { + const appOptions = superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }); + await placeWidgetAsync("app", [], appOptions, { width: 1, height: 1 }); + } + + // Place general widgets from config + 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) continue; + if (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(config.kind, [], options ? superjson.stringify(options) : undefined, { + width: config.width, + height: config.height, + }); + } + } + } + + await nextOnboardingStepAsync(db, undefined); + }), }); 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]; From e3fb8572cd0b35fb5ba0d192fb53800e876967b3 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 1 May 2026 09:58:47 +0200 Subject: [PATCH 04/53] feat(ui): add reusable integration select grid and modal components Create IntegrationSelectGrid (single-select) and IntegrationMultiSelectGrid (multi-select) components with search, category badges, widget count tooltips, and featured integration sorting. Use SimpleGrid to prevent horizontal overflow. Add stepper modal for creating integrations from the dashboard. --- .../integration-multi-select-grid.tsx | 201 ++++++++++++++++++ .../integration/integration-picker-page.tsx | 25 +++ .../integration/integration-select-grid.tsx | 171 +++++++++++++++ .../integration/integration-select-modal.tsx | 56 +++++ 4 files changed, 453 insertions(+) create mode 100644 apps/nextjs/src/components/integration/integration-multi-select-grid.tsx create mode 100644 apps/nextjs/src/components/integration/integration-picker-page.tsx create mode 100644 apps/nextjs/src/components/integration/integration-select-grid.tsx create mode 100644 apps/nextjs/src/components/integration/integration-select-modal.tsx diff --git a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx new file mode 100644 index 000000000..e253f8468 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Badge, Card, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; +import { IconCheck, IconPuzzle, IconSearch } from "@tabler/icons-react"; + +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { featuredIntegrations, getIntegrationName, integrationDefs, integrationKinds } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { IntegrationAvatar } from "@homarr/ui"; +import { widgetImports } from "@homarr/widgets"; + +interface IntegrationMultiSelectGridProps { + onSelectionChange: (kinds: IntegrationKind[]) => void; + enableMockIntegration?: boolean; +} + +const getWidgetsByIntegration = () => { + const map: Record = Object.fromEntries( + integrationKinds.map((kind) => [kind, []]), + ) as Record; + + for (const [widgetKind, widget] of Object.entries(widgetImports) as [ + WidgetKind, + (typeof widgetImports)[WidgetKind], + ][]) { + const supported = + "supportedIntegrations" in widget.definition ? (widget.definition.supportedIntegrations as string[]) : []; + for (const kind of supported) { + if (kind in map) { + map[kind as IntegrationKind].push(widgetKind); + } + } + } + return map; +}; + +const widgetsByIntegration = getWidgetsByIntegration(); + +const categoryLabels: Record = { + dnsHole: "DNS", + mediaService: "Media", + calendar: "Calendar", + mediaSearch: "Search", + mediaRelease: "Releases", + mediaRequest: "Requests", + downloadClient: "Downloads", + usenet: "Usenet", + torrent: "Torrent", + miscellaneous: "Misc", + smartHomeServer: "Smart Home", + indexerManager: "Indexer", + healthMonitoring: "Health", + search: "Search", + mediaTranscoding: "Transcoding", + networkController: "Network", + releasesProvider: "Releases", + notifications: "Notifications", + firewall: "Firewall", + timetable: "Timetable", + photoService: "Photos", + notes: "Notes", + mediaMonitoring: "Monitoring", + speedtest: "Speed Test", +}; + +const CARD_HEIGHT = 180; + +export const IntegrationMultiSelectGrid = ({ + onSelectionChange, + enableMockIntegration = false, +}: IntegrationMultiSelectGridProps) => { + const [search, setSearch] = useState(""); + const [selectedKinds, setSelectedKinds] = useState>(new Set()); + const t = useI18n(); + + useEffect(() => { + onSelectionChange(Array.from(selectedKinds)); + }, [selectedKinds, onSelectionChange]); + + const toggleKind = useCallback((kind: IntegrationKind) => { + setSelectedKinds((prev) => { + const next = new Set(prev); + if (next.has(kind)) { + next.delete(kind); + } else { + next.add(kind); + } + return next; + }); + }, []); + + const integrations = useMemo(() => { + return integrationKinds + .filter((kind) => enableMockIntegration || kind !== "mock") + .map((kind) => ({ + kind, + name: getIntegrationName(kind), + categories: [...new Set(integrationDefs[kind].category.flat())] as string[], + widgets: widgetsByIntegration[kind], + })) + .sort((left, right) => { + const leftIdx = featuredIntegrations.indexOf(left.kind); + const rightIdx = featuredIntegrations.indexOf(right.kind); + if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; + if (leftIdx !== -1) return -1; + if (rightIdx !== -1) return 1; + return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); + }); + }, [enableMockIntegration]); + + const filtered = useMemo( + () => integrations.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())), + [integrations, search], + ); + + return ( + + setSearch(event.currentTarget.value)} + leftSection={} + placeholder={`${t("integration.page.list.search")}...`} + data-autofocus + /> + + + + {filtered.map((integration) => { + const isSelected = selectedKinds.has(integration.kind); + return ( + toggleKind(integration.kind)} + withBorder + > + + + + {integration.name} + + {isSelected && } + + +
+ +
+ + + + {integration.categories.slice(0, 2).map((cat) => ( + + {categoryLabels[cat] ?? cat} + + ))} + + {integration.widgets.length > 0 ? ( + + {integration.widgets.map((widgetKind) => ( + + {t(`widget.${widgetKind}.name`)} + + ))} + + } + > + }> + {integration.widgets.length} {integration.widgets.length === 1 ? "widget" : "widgets"} + + + ) : ( + + No widgets + + )} +
+
+ + ); + })} + + {filtered.length === 0 && ( +
+ {t("common.noResults")} +
+ )} + + + + ); +}; diff --git a/apps/nextjs/src/components/integration/integration-picker-page.tsx b/apps/nextjs/src/components/integration/integration-picker-page.tsx new file mode 100644 index 000000000..cd4618ef7 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-picker-page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; + +import type { IntegrationKind } from "@homarr/definitions"; + +import { IntegrationSelectGrid } from "./integration-select-grid"; + +interface IntegrationPickerPageProps { + enableMockIntegration: boolean; +} + +export const IntegrationPickerPage = ({ enableMockIntegration }: IntegrationPickerPageProps) => { + const router = useRouter(); + + const handleSelect = useCallback( + (kind: IntegrationKind) => { + router.push(`/manage/integrations/new?kind=${kind}`); + }, + [router], + ); + + return ; +}; diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx new file mode 100644 index 000000000..321473d77 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Badge, Card, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; +import { IconPuzzle, IconSearch } from "@tabler/icons-react"; + +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { featuredIntegrations, getIntegrationName, integrationDefs, integrationKinds } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { IntegrationAvatar } from "@homarr/ui"; +import { widgetImports } from "@homarr/widgets"; + +interface IntegrationSelectGridProps { + onSelect: (kind: IntegrationKind) => void; + enableMockIntegration?: boolean; +} + +const getWidgetsByIntegration = () => { + const map: Record = Object.fromEntries( + integrationKinds.map((kind) => [kind, []]), + ) as Record; + + for (const [widgetKind, widget] of Object.entries(widgetImports) as [ + WidgetKind, + (typeof widgetImports)[WidgetKind], + ][]) { + const supported = + "supportedIntegrations" in widget.definition ? (widget.definition.supportedIntegrations as string[]) : []; + for (const kind of supported) { + if (kind in map) { + map[kind as IntegrationKind].push(widgetKind); + } + } + } + return map; +}; + +const widgetsByIntegration = getWidgetsByIntegration(); + +const categoryLabels: Record = { + dnsHole: "DNS", + mediaService: "Media", + calendar: "Calendar", + mediaSearch: "Search", + mediaRelease: "Releases", + mediaRequest: "Requests", + downloadClient: "Downloads", + usenet: "Usenet", + torrent: "Torrent", + miscellaneous: "Misc", + smartHomeServer: "Smart Home", + indexerManager: "Indexer", + healthMonitoring: "Health", + search: "Search", + mediaTranscoding: "Transcoding", + networkController: "Network", + releasesProvider: "Releases", + notifications: "Notifications", + firewall: "Firewall", + timetable: "Timetable", + photoService: "Photos", + notes: "Notes", + mediaMonitoring: "Monitoring", + speedtest: "Speed Test", +}; + +const CARD_HEIGHT = 180; + +export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => { + const [search, setSearch] = useState(""); + const t = useI18n(); + + const integrations = useMemo(() => { + return integrationKinds + .filter((kind) => enableMockIntegration || kind !== "mock") + .map((kind) => ({ + kind, + name: getIntegrationName(kind), + categories: [...new Set(integrationDefs[kind].category.flat())] as string[], + widgets: widgetsByIntegration[kind], + })) + .sort((left, right) => { + const leftIdx = featuredIntegrations.indexOf(left.kind); + const rightIdx = featuredIntegrations.indexOf(right.kind); + if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; + if (leftIdx !== -1) return -1; + if (rightIdx !== -1) return 1; + return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); + }); + }, [enableMockIntegration]); + + const filtered = useMemo( + () => integrations.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())), + [integrations, search], + ); + + return ( + + setSearch(event.currentTarget.value)} + leftSection={} + placeholder={`${t("integration.page.list.search")}...`} + data-autofocus + /> + + + + {filtered.map((integration) => ( + onSelect(integration.kind)} + withBorder + > + + + {integration.name} + + +
+ +
+ + + + {integration.categories.slice(0, 2).map((cat) => ( + + {categoryLabels[cat] ?? cat} + + ))} + + {integration.widgets.length > 0 ? ( + + {integration.widgets.map((widgetKind) => ( + + {t(`widget.${widgetKind}.name`)} + + ))} + + } + > + }> + {integration.widgets.length} {integration.widgets.length === 1 ? "widget" : "widgets"} + + + ) : ( + + No widgets + + )} +
+
+ + ))} + + {filtered.length === 0 && ( +
+ {t("common.noResults")} +
+ )} + + + + ); +}; diff --git a/apps/nextjs/src/components/integration/integration-select-modal.tsx b/apps/nextjs/src/components/integration/integration-select-modal.tsx new file mode 100644 index 000000000..c93f1d5de --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-select-modal.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useState } from "react"; +import { Group, ScrollArea, Title } from "@mantine/core"; +import { IconArrowLeft } from "@tabler/icons-react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { getIntegrationName } from "@homarr/definitions"; +import { createModal } from "@homarr/modals"; +import { IntegrationAvatar } from "@homarr/ui"; + +import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; +import { IntegrationSelectGrid } from "./integration-select-grid"; + +interface IntegrationSelectModalProps { + onSuccess?: () => void; + enableMockIntegration?: boolean; +} + +export const IntegrationSelectModal = createModal(({ actions, innerProps }) => { + const [step, setStep] = useState<"select" | "form">("select"); + const [selectedKind, setSelectedKind] = useState(null); + + const handleSelect = (kind: IntegrationKind) => { + setSelectedKind(kind); + setStep("form"); + }; + + const handleBack = () => { + setStep("select"); + setSelectedKind(null); + }; + + const handleSuccess = () => { + innerProps.onSuccess?.(); + actions.closeModal(); + }; + + if (step === "form" && selectedKind) { + return ( + + + + + {getIntegrationName(selectedKind)} + + + + ); + } + + return ; +}).withOptions({ + defaultTitle: (t) => t("integration.action.create"), + size: 1200, +}); From 7985ff953a03e4ba5c6e674a72bdecd69042c5aa Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 1 May 2026 09:58:54 +0200 Subject: [PATCH 05/53] feat(onboarding): add integration configuration stepper Add 3-phase integration setup to onboarding: multi-select grid, per-integration configuration with NewIntegrationForm stepper, and finishing phase that populates the dashboard and shows final actions. Merge finish step actions into the integrations step. Add translation keys for configure phase. --- .../init/_steps/finish/init-finish.tsx | 24 +-- .../_steps/integrations/init-integrations.tsx | 185 ++++++++++++++++++ apps/nextjs/src/app/[locale]/init/page.tsx | 2 + packages/translation/src/lang/en-gb.json | 14 ++ packages/translation/src/lang/en.json | 14 ++ 5 files changed, 223 insertions(+), 16 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx 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 288b9adae..7e426bcfb 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,6 +1,6 @@ import type { MantineColor } from "@mantine/core"; import { Button, Card, Stack, Text } from "@mantine/core"; -import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react"; +import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react"; import { isProviderEnabled } from "@homarr/auth/server"; import { getMantineColor } from "@homarr/common"; @@ -13,27 +13,19 @@ import type { TablerIcon } from "@homarr/ui"; export const InitFinish = async () => { const firstBoard = await db.query.boards.findFirst({ columns: { name: true } }); const tFinish = await getScopedI18n("init.step.finish"); + const boardName = firstBoard?.name ?? "dashboard"; return ( {tFinish("description")} - {firstBoard ? ( - - {tFinish("action.goToBoard", { name: firstBoard.name })} - - ) : ( - - {tFinish("action.createBoard")} - - )} + + {tFinish("action.goToBoard", { name: boardName })} + {isProviderEnabled("credentials") && ( { + const [phase, setPhase] = useState("select"); + const [selectedKinds, setSelectedKinds] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const t = useScopedI18n("init.step.integrations"); + + const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation(); + + const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => { + setSelectedKinds(kinds); + }, []); + + const handleContinueToConfig = () => { + setCurrentIndex(0); + setPhase("configure"); + }; + + const handleSkipAll = () => { + setPhase("done"); + }; + + const advanceOrFinish = () => { + if (currentIndex + 1 >= selectedKinds.length) { + setPhase("done"); + } else { + setCurrentIndex((i) => i + 1); + } + }; + + const backToSelect = () => { + setPhase("select"); + setCurrentIndex(0); + }; + + if (phase === "done") { + return ; + } + + if (phase === "configure") { + const currentKind = selectedKinds[currentIndex]; + if (currentKind === undefined) { + return null; + } + + return ( + + + + {selectedKinds.map((kind, index) => ( + } + completedIcon={} + label={index === currentIndex ? getIntegrationName(kind) : undefined} + /> + ))} + + + + {t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })} + + + + + + ); + } + + return ( + + + {t("description")} + + + + + + + + + + ); +}; + +const FinishingPhase = ({ setupIntegrations }: { setupIntegrations: () => Promise }) => { + const t = useScopedI18n("init.step"); + const [isReady, setIsReady] = useState(false); + const calledRef = useRef(false); + + useEffect(() => { + if (calledRef.current) return; + calledRef.current = true; + + void setupIntegrations().then(() => { + setIsReady(true); + }); + }, [setupIntegrations]); + + if (!isReady) { + return ( + +
+ + + {t("integrations.configure.finishing")} + +
+
+ ); + } + + return ( + + + + + + {t("finish.title")} + + + {t("finish.description")} + + + + + + + + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/init/page.tsx b/apps/nextjs/src/app/[locale]/init/page.tsx index 5684e5709..61dd1025f 100644 --- a/apps/nextjs/src/app/[locale]/init/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/page.tsx @@ -13,6 +13,7 @@ import { BackToStart } from "./_steps/back"; import { InitFinish } from "./_steps/finish/init-finish"; import { InitGroup } from "./_steps/group/init-group"; import { InitImport } from "./_steps/import/init-import"; +import { InitIntegrations } from "./_steps/integrations/init-integrations"; import { InitSettings } from "./_steps/settings/init-settings"; import { InitStart } from "./_steps/start/init-start"; import { InitUser } from "./_steps/user/init-user"; @@ -23,6 +24,7 @@ const stepComponents: Record MaybePromise Date: Fri, 1 May 2026 09:59:01 +0200 Subject: [PATCH 06/53] feat(integrations): add grid picker page and dashboard integration action Refactor integration creation page to show a grid picker before the form. Add "Add integration" action to dashboard header menu. Convert app select modal to a two-step stepper. Standardize modal widths to 1200 for item, integration, and app select modals. --- .../boards/(content)/_header-actions.tsx | 11 +++ .../new/_integration-new-form.tsx | 45 +++++++----- .../[locale]/manage/integrations/new/page.tsx | 30 +++++--- .../board/items/item-select-modal.tsx | 2 +- .../src/apps/app-select-modal.tsx | 71 +++++++++++++------ 5 files changed, 112 insertions(+), 47 deletions(-) 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 f1185430c..e160430ba 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 = () => { @@ -67,6 +69,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(); @@ -108,6 +111,10 @@ const AddMenu = () => { }); }, [openAppSelectModal, createItem]); + const handleAddIntegration = useCallback(() => { + openIntegrationSelectModal({}); + }, [openIntegrationSelectModal]); + return ( @@ -127,6 +134,10 @@ const AddMenu = () => { {t("app.action.add")} + } onClick={handleAddIntegration}> + {t("integration.action.create")} + + } onClick={handleAddCategory}> diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index 016b9336c..f0577bd76 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -45,9 +45,11 @@ import { IntegrationTestConnectionError } from "../_components/test-connection/i import type { AnyMappedTestConnectionError } from "../_components/test-connection/types"; interface NewIntegrationFormProps { - searchParams: Partial> & { - kind: IntegrationKind; - }; + kind: IntegrationKind; + initialUrl?: string; + initialName?: string; + onSuccess?: () => void; + onCancel?: () => void; } const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( @@ -58,29 +60,28 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( }), ); -export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => { +export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, onCancel }: NewIntegrationFormProps) => { const t = useI18n(); - const secretKinds = getAllSecretKindOptions(searchParams.kind); + const secretKinds = getAllSecretKindOptions(kind); const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url")); const router = useRouter(); const { data: session } = useSession(); const canCreateApps = session?.user.permissions.includes("app-create") ?? false; - let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? ""; + let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? ""; if (hasUrlSecret) { - // Placeholder Url, replaced with origin of the secret Url on submit url = "http://localhost"; } const form = useZodForm(formSchema, { initialValues: { - name: searchParams.name ?? getIntegrationName(searchParams.kind), + name: initialName ?? getIntegrationName(kind), url, secrets: secretKinds[0].map((kind) => ({ kind, value: "", })), attemptSearchEngineCreation: true, - hasApp: false, + hasApp: true, appHref: url, appId: null, }, @@ -106,7 +107,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => : { name: values.name, href: hasCustomHref ? appHref : url, - iconUrl: getIconUrl(searchParams.kind), + iconUrl: getIconUrl(kind), description: null, pingUrl: url, } @@ -114,7 +115,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => await createIntegrationAsync( { - kind: searchParams.kind, + kind, ...values, url, app, @@ -136,7 +137,11 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => message: t("integration.page.create.notification.success.message"), }); - router.push("/manage/integrations"); + if (onSuccess) { + onSuccess(); + } else { + router.push("/manage/integrations"); + } }, onError: () => { showErrorNotification({ @@ -148,7 +153,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => ); }; - const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search"); + const supportsSearchEngine = integrationDefs[kind].category.flat().includes("search"); return (
void handleSubmitAsync(value))}> @@ -184,7 +189,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => @@ -193,9 +198,15 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => - + {onCancel ? ( + + ) : ( + + )} diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx index 1938a569e..803504329 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -3,21 +3,21 @@ import { Container, Group, Stack, Title } from "@mantine/core"; import { z } from "zod/v4"; import { auth } from "@homarr/auth/next"; -import type { IntegrationKind } from "@homarr/definitions"; import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; import { IntegrationAvatar } from "@homarr/ui"; -import type { integrationCreateSchema } from "@homarr/validation/integration"; +import { IntegrationPickerPage } from "~/components/integration/integration-picker-page"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { env } from "~/env"; import { NewIntegrationForm } from "./_integration-new-form"; interface NewIntegrationPageProps { - searchParams: Promise< - Partial> & { - kind: IntegrationKind; - } - >; + searchParams: Promise<{ + kind?: string; + url?: string; + name?: string; + }>; } export default async function IntegrationsNewPage(props: NewIntegrationPageProps) { @@ -28,8 +28,20 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps } const result = z.enum(integrationKinds).safeParse(searchParams.kind); + if (!result.success) { - notFound(); + const t = await getScopedI18n("integration"); + return ( + <> + + + + {t("action.create")} + + + + + ); } const tCreate = await getScopedI18n("integration.page.create"); @@ -45,7 +57,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps {tCreate("title", { name: getIntegrationName(currentKind) })} - + 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 5066f5539..ee8b05b58 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -70,7 +70,7 @@ export const ItemSelectModal = createModal(({ actions }) => { ); }).withOptions({ defaultTitle: (t) => t("item.create.title"), - size: "xl", + size: 1200, }); const WidgetItem = ({ diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx index e488eb3a6..a4f37fcb7 100644 --- a/packages/modals-collection/src/apps/app-select-modal.tsx +++ b/packages/modals-collection/src/apps/app-select-modal.tsx @@ -1,13 +1,15 @@ import { useMemo, useState } from "react"; -import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core"; -import { IconPlus, IconSearch } from "@tabler/icons-react"; +import { Button, Card, Center, Grid, Group, Input, ScrollArea, Stack, Text, Title } from "@mantine/core"; +import { IconArrowLeft, IconPlus, IconSearch } from "@tabler/icons-react"; +import type { z } from "zod/v4"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { createModal, useModalAction } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal"; +import { AppForm } from "@homarr/forms-collection"; +import { createModal } from "@homarr/modals"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import type { appManageSchema } from "@homarr/validation/app"; interface AppSelectModalProps { onSelect?: (app: RouterOutputs["app"]["selectable"][number]) => void; @@ -15,10 +17,20 @@ interface AppSelectModalProps { } export const AppSelectModal = createModal(({ actions, innerProps }) => { + const [step, setStep] = useState<"select" | "form">("select"); const [search, setSearch] = useState(""); const t = useI18n(); - const { data: apps = [], isPending } = clientApi.app.selectable.useQuery(); - const { openModal: openQuickAddAppModal } = useModalAction(QuickAddAppModal); + const tScoped = useScopedI18n("app.page.create.notification"); + const { data: apps = [], isPending: isLoadingApps } = clientApi.app.selectable.useQuery(); + + const { mutate, isPending: isCreating } = clientApi.app.create.useMutation({ + onError: () => { + showErrorNotification({ + title: tScoped("error.title"), + message: tScoped("error.message"), + }); + }, + }); const filteredApps = useMemo( () => @@ -29,23 +41,42 @@ 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); - } + const handleCreateSubmit = (values: z.infer) => { + mutate(values, { + onSuccess(app) { + showSuccessNotification({ + title: tScoped("success.title"), + message: tScoped("success.message"), + }); + innerProps.onSelect?.(app); actions.closeModal(); }, }); }; + if (step === "form") { + return ( + + setStep("select")}> + + {t("app.action.create.title")} + + + + ); + } + return ( (({ actions, inner {t("app.action.create.description")} - @@ -108,7 +139,7 @@ export const AppSelectModal = createModal(({ actions, inner ))} - {filteredApps.length === 0 && !isPending && ( + {filteredApps.length === 0 && !isLoadingApps && (
{t("app.action.select.noResults")} @@ -120,5 +151,5 @@ export const AppSelectModal = createModal(({ actions, inner ); }).withOptions({ defaultTitle: (t) => t("app.action.select.title"), - size: "xl", + size: 1200, }); From e0c2978f523c67eba5a98f9ba9f245e36fb49268 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 1 May 2026 10:12:14 +0200 Subject: [PATCH 07/53] fix: resolve CI failures from integrations onboarding step - Fix TypeScript error: InitIntegrations returning null by transitioning to done phase instead - Fix TypeScript error: Object.fromEntries cast in integration grids by typing the array elements - Update E2E onboarding tests to process the new integrations step (skip for now) and increase finish assertion timeout for async mutation --- .../[locale]/init/_steps/integrations/init-integrations.tsx | 3 ++- .../components/integration/integration-multi-select-grid.tsx | 4 ++-- .../src/components/integration/integration-select-grid.tsx | 4 ++-- e2e/onboarding.spec.ts | 2 ++ e2e/shared/actions/onboarding-actions.ts | 5 +++++ e2e/shared/assertions/onboarding-assertions.ts | 2 +- 6 files changed, 14 insertions(+), 6 deletions(-) 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 index a8f939b7b..2a188ef5d 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx @@ -57,7 +57,8 @@ export const InitIntegrations = () => { if (phase === "configure") { const currentKind = selectedKinds[currentIndex]; if (currentKind === undefined) { - return null; + setPhase("done"); + return <>; } return ( diff --git a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx index e253f8468..3e5c542ae 100644 --- a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx @@ -16,8 +16,8 @@ interface IntegrationMultiSelectGridProps { } const getWidgetsByIntegration = () => { - const map: Record = Object.fromEntries( - integrationKinds.map((kind) => [kind, []]), + const map = Object.fromEntries( + integrationKinds.map((kind) => [kind, [] as WidgetKind[]]), ) as Record; for (const [widgetKind, widget] of Object.entries(widgetImports) as [ diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx index 321473d77..e846b54e5 100644 --- a/apps/nextjs/src/components/integration/integration-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx @@ -16,8 +16,8 @@ interface IntegrationSelectGridProps { } const getWidgetsByIntegration = () => { - const map: Record = Object.fromEntries( - integrationKinds.map((kind) => [kind, []]), + const map = Object.fromEntries( + integrationKinds.map((kind) => [kind, [] as WidgetKind[]]), ) as Record; for (const [widgetKind, widget] of Object.entries(widgetImports) as [ diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts index d2731853a..0f09bb52e 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 182203fcf..4db61692b 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 a2f9ccb2f..612b71e13 100644 --- a/e2e/shared/assertions/onboarding-assertions.ts +++ b/e2e/shared/assertions/onboarding-assertions.ts @@ -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 }); } } From 62d571621f0d5a23c8cd39811fa70dd4a2857ecf Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 1 May 2026 10:15:44 +0200 Subject: [PATCH 08/53] chore: format fix(lint): remove empty fragment and redundant undefined argument chore: format --- .../init/_steps/integrations/init-integrations.tsx | 9 +++++++-- .../integration/integration-multi-select-grid.tsx | 7 ++++--- .../components/integration/integration-select-grid.tsx | 7 ++++--- packages/api/src/router/onboard/onboard-router.ts | 2 +- 4 files changed, 16 insertions(+), 9 deletions(-) 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 index 2a188ef5d..98c4976a4 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx @@ -57,8 +57,13 @@ export const InitIntegrations = () => { if (phase === "configure") { const currentKind = selectedKinds[currentIndex]; if (currentKind === undefined) { - setPhase("done"); - return <>; + return ( + +
+ +
+
+ ); } return ( diff --git a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx index 3e5c542ae..3f0972f6a 100644 --- a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx @@ -16,9 +16,10 @@ interface IntegrationMultiSelectGridProps { } const getWidgetsByIntegration = () => { - const map = Object.fromEntries( - integrationKinds.map((kind) => [kind, [] as WidgetKind[]]), - ) as Record; + const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< + IntegrationKind, + WidgetKind[] + >; for (const [widgetKind, widget] of Object.entries(widgetImports) as [ WidgetKind, diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx index e846b54e5..8637137de 100644 --- a/apps/nextjs/src/components/integration/integration-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx @@ -16,9 +16,10 @@ interface IntegrationSelectGridProps { } const getWidgetsByIntegration = () => { - const map = Object.fromEntries( - integrationKinds.map((kind) => [kind, [] as WidgetKind[]]), - ) as Record; + const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< + IntegrationKind, + WidgetKind[] + >; for (const [widgetKind, widget] of Object.entries(widgetImports) as [ WidgetKind, diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index 140d2205b..bf733b920 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -177,6 +177,6 @@ export const onboardRouter = createTRPCRouter({ } } - await nextOnboardingStepAsync(db, undefined); + await nextOnboardingStepAsync(db); }), }); From b8b2097551d84c469c0055aed664233b6b7975e0 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 1 May 2026 10:29:32 +0200 Subject: [PATCH 09/53] fix(onboarding): fix nextOnboardingStepAsync --- packages/api/src/router/onboard/onboard-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index bf733b920..75cffb576 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -177,6 +177,6 @@ export const onboardRouter = createTRPCRouter({ } } - await nextOnboardingStepAsync(db); + await nextOnboardingStepAsync(db, "start"); }), }); From 8fc758721c8e56f6dc78de6a8eec27f85f32bb45 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 16:53:47 +0200 Subject: [PATCH 10/53] fix(definitions): correct featured integrations and default configs - Remove unused timetable skip entry from defaultWidgetConfigs - Use "seerr" instead of "jellyseerr" in featuredIntegrations - Add "Support Homarr" OpenCollective link to defaultBookmarkApps --- packages/definitions/src/widget-integration-map.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index a85487eab..7cbff61de 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -60,7 +60,6 @@ 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: 1, options: { title: "Useful Links", layout: "grid", openNewTab: true } }, - { kind: "timetable", width: 2, height: 2, skip: true }, ]; export const generalWidgets: WidgetKind[] = defaultWidgetConfigs @@ -73,7 +72,7 @@ export const featuredIntegrations: readonly IntegrationKind[] = [ "prowlarr", "sabNzbd", "qBittorrent", - "jellyseerr", + "seerr", "jellyfin", ]; @@ -93,4 +92,9 @@ export const defaultBookmarkApps = [ 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/walkxcode/dashboard-icons@master/svg/open-collective.svg", + }, ]; From 9c0e73922d3c1a72bd76a96b5319573cdbd3a5a1 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 16:53:53 +0200 Subject: [PATCH 11/53] fix(i18n): add translation keys for integration grid categories and widget counts Category labels and widget count badges in the integration grid were hardcoded English strings. Add proper i18n keys under integration.grid and integration.category so they can be translated. --- packages/translation/src/lang/en.json | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 9cb8ff010..37e2ae207 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -749,6 +749,37 @@ "action": { "create": "New integration" }, + "grid": { + "widgetCount": "{count} widgets", + "noWidgets": "No widgets" + }, + "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", From bfa2295a7cc7db9758685731ca2590ab8f39ee32 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 16:54:02 +0200 Subject: [PATCH 12/53] refactor(ui): extract shared integration grid logic and deduplicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both IntegrationMultiSelectGrid and IntegrationSelectGrid had duplicated logic for building widget-by-integration maps, category labels, sorting, and filtering. Extract shared code into integration-grid-shared.ts using objectEntries and IntegrationKind typing. Use i18n keys for category badges and widget counts instead of hardcoded strings. Remove useEffect for onSelectionChange in multi-select grid — call it directly in the toggle callback. --- .../integration/integration-grid-shared.ts | 84 +++++++++++++ .../integration-multi-select-grid.tsx | 114 ++++-------------- .../integration/integration-select-grid.tsx | 94 ++------------- 3 files changed, 119 insertions(+), 173 deletions(-) create mode 100644 apps/nextjs/src/components/integration/integration-grid-shared.ts 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..fcd08dc38 --- /dev/null +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -0,0 +1,84 @@ +import { objectEntries } from "@homarr/common"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import { featuredIntegrations, getIntegrationName, integrationDefs, integrationKinds } from "@homarr/definitions"; +import { widgetImports } from "@homarr/widgets"; + +export const CARD_HEIGHT = 180; + +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 = (enableMockIntegration: boolean): IntegrationGridItem[] => + integrationKinds + .filter((kind) => enableMockIntegration || kind !== "mock") + .map((kind) => ({ + kind, + name: getIntegrationName(kind), + categories: [...new Set(integrationDefs[kind].category.flat())] as string[], + widgets: widgetsByIntegration[kind], + })) + .sort((left, right) => { + const leftIdx = featuredIntegrations.indexOf(left.kind); + const rightIdx = featuredIntegrations.indexOf(right.kind); + if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; + if (leftIdx !== -1) return -1; + if (rightIdx !== -1) return 1; + return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); + }); + +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 index 3f0972f6a..74762ae8b 100644 --- a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx @@ -1,72 +1,25 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Badge, Card, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; import { IconCheck, IconPuzzle, IconSearch } from "@tabler/icons-react"; -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { featuredIntegrations, getIntegrationName, integrationDefs, integrationKinds } from "@homarr/definitions"; +import type { IntegrationKind } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; import { IntegrationAvatar } from "@homarr/ui"; -import { widgetImports } from "@homarr/widgets"; + +import { + buildSortedIntegrations, + CARD_HEIGHT, + categoryTranslationKeys, + filterIntegrations, +} from "./integration-grid-shared"; interface IntegrationMultiSelectGridProps { onSelectionChange: (kinds: IntegrationKind[]) => void; enableMockIntegration?: boolean; } -const getWidgetsByIntegration = () => { - const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< - IntegrationKind, - WidgetKind[] - >; - - for (const [widgetKind, widget] of Object.entries(widgetImports) as [ - WidgetKind, - (typeof widgetImports)[WidgetKind], - ][]) { - const supported = - "supportedIntegrations" in widget.definition ? (widget.definition.supportedIntegrations as string[]) : []; - for (const kind of supported) { - if (kind in map) { - map[kind as IntegrationKind].push(widgetKind); - } - } - } - return map; -}; - -const widgetsByIntegration = getWidgetsByIntegration(); - -const categoryLabels: Record = { - dnsHole: "DNS", - mediaService: "Media", - calendar: "Calendar", - mediaSearch: "Search", - mediaRelease: "Releases", - mediaRequest: "Requests", - downloadClient: "Downloads", - usenet: "Usenet", - torrent: "Torrent", - miscellaneous: "Misc", - smartHomeServer: "Smart Home", - indexerManager: "Indexer", - healthMonitoring: "Health", - search: "Search", - mediaTranscoding: "Transcoding", - networkController: "Network", - releasesProvider: "Releases", - notifications: "Notifications", - firewall: "Firewall", - timetable: "Timetable", - photoService: "Photos", - notes: "Notes", - mediaMonitoring: "Monitoring", - speedtest: "Speed Test", -}; - -const CARD_HEIGHT = 180; - export const IntegrationMultiSelectGrid = ({ onSelectionChange, enableMockIntegration = false, @@ -75,46 +28,23 @@ export const IntegrationMultiSelectGrid = ({ const [selectedKinds, setSelectedKinds] = useState>(new Set()); const t = useI18n(); - useEffect(() => { - onSelectionChange(Array.from(selectedKinds)); - }, [selectedKinds, onSelectionChange]); - - const toggleKind = useCallback((kind: IntegrationKind) => { - setSelectedKinds((prev) => { - const next = new Set(prev); + const toggleKind = useCallback( + (kind: IntegrationKind) => { + const next = new Set(selectedKinds); if (next.has(kind)) { next.delete(kind); } else { next.add(kind); } - return next; - }); - }, []); - - const integrations = useMemo(() => { - return integrationKinds - .filter((kind) => enableMockIntegration || kind !== "mock") - .map((kind) => ({ - kind, - name: getIntegrationName(kind), - categories: [...new Set(integrationDefs[kind].category.flat())] as string[], - widgets: widgetsByIntegration[kind], - })) - .sort((left, right) => { - const leftIdx = featuredIntegrations.indexOf(left.kind); - const rightIdx = featuredIntegrations.indexOf(right.kind); - if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; - if (leftIdx !== -1) return -1; - if (rightIdx !== -1) return 1; - return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); - }); - }, [enableMockIntegration]); - - const filtered = useMemo( - () => integrations.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())), - [integrations, search], + setSelectedKinds(next); + onSelectionChange(Array.from(next)); + }, + [selectedKinds, onSelectionChange], ); + const integrations = useMemo(() => buildSortedIntegrations(enableMockIntegration), [enableMockIntegration]); + const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]); + return ( {integration.categories.slice(0, 2).map((cat) => ( - {categoryLabels[cat] ?? cat} + {categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat} ))} @@ -176,12 +106,12 @@ export const IntegrationMultiSelectGrid = ({ } > }> - {integration.widgets.length} {integration.widgets.length === 1 ? "widget" : "widgets"} + {t("integration.grid.widgetCount", { count: String(integration.widgets.length) })} ) : ( - No widgets + {t("integration.grid.noWidgets")} )} diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx index 8637137de..2a985d7c3 100644 --- a/apps/nextjs/src/components/integration/integration-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx @@ -4,96 +4,28 @@ import { useMemo, useState } from "react"; import { Badge, Card, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; import { IconPuzzle, IconSearch } from "@tabler/icons-react"; -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { featuredIntegrations, getIntegrationName, integrationDefs, integrationKinds } from "@homarr/definitions"; +import type { IntegrationKind } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; import { IntegrationAvatar } from "@homarr/ui"; -import { widgetImports } from "@homarr/widgets"; + +import { + buildSortedIntegrations, + CARD_HEIGHT, + categoryTranslationKeys, + filterIntegrations, +} from "./integration-grid-shared"; interface IntegrationSelectGridProps { onSelect: (kind: IntegrationKind) => void; enableMockIntegration?: boolean; } -const getWidgetsByIntegration = () => { - const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record< - IntegrationKind, - WidgetKind[] - >; - - for (const [widgetKind, widget] of Object.entries(widgetImports) as [ - WidgetKind, - (typeof widgetImports)[WidgetKind], - ][]) { - const supported = - "supportedIntegrations" in widget.definition ? (widget.definition.supportedIntegrations as string[]) : []; - for (const kind of supported) { - if (kind in map) { - map[kind as IntegrationKind].push(widgetKind); - } - } - } - return map; -}; - -const widgetsByIntegration = getWidgetsByIntegration(); - -const categoryLabels: Record = { - dnsHole: "DNS", - mediaService: "Media", - calendar: "Calendar", - mediaSearch: "Search", - mediaRelease: "Releases", - mediaRequest: "Requests", - downloadClient: "Downloads", - usenet: "Usenet", - torrent: "Torrent", - miscellaneous: "Misc", - smartHomeServer: "Smart Home", - indexerManager: "Indexer", - healthMonitoring: "Health", - search: "Search", - mediaTranscoding: "Transcoding", - networkController: "Network", - releasesProvider: "Releases", - notifications: "Notifications", - firewall: "Firewall", - timetable: "Timetable", - photoService: "Photos", - notes: "Notes", - mediaMonitoring: "Monitoring", - speedtest: "Speed Test", -}; - -const CARD_HEIGHT = 180; - export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => { const [search, setSearch] = useState(""); const t = useI18n(); - const integrations = useMemo(() => { - return integrationKinds - .filter((kind) => enableMockIntegration || kind !== "mock") - .map((kind) => ({ - kind, - name: getIntegrationName(kind), - categories: [...new Set(integrationDefs[kind].category.flat())] as string[], - widgets: widgetsByIntegration[kind], - })) - .sort((left, right) => { - const leftIdx = featuredIntegrations.indexOf(left.kind); - const rightIdx = featuredIntegrations.indexOf(right.kind); - if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx; - if (leftIdx !== -1) return -1; - if (rightIdx !== -1) return 1; - return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); - }); - }, [enableMockIntegration]); - - const filtered = useMemo( - () => integrations.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim())), - [integrations, search], - ); + const integrations = useMemo(() => buildSortedIntegrations(enableMockIntegration), [enableMockIntegration]); + const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]); return ( @@ -128,7 +60,7 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false {integration.categories.slice(0, 2).map((cat) => ( - {categoryLabels[cat] ?? cat} + {categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat} ))} @@ -147,12 +79,12 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false } > }> - {integration.widgets.length} {integration.widgets.length === 1 ? "widget" : "widgets"} + {t("integration.grid.widgetCount", { count: String(integration.widgets.length) })} ) : ( - No widgets + {t("integration.grid.noWidgets")} )} From 34500366b48a69c8e6b6bd22408aabfb4cae57a1 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 16:54:12 +0200 Subject: [PATCH 13/53] fix(ui): add custom xxl modal size to prevent overflow on small screens Hardcoded size: 1200 could overflow on narrow viewports. Add a custom "xxl" size (75rem) to the Mantine Modal theme via a vars resolver. Mantine caps modal width at 100vw so it naturally adapts on mobile. Use "xxl" in item-select, app-select, and integration-select modals. --- .../src/components/board/items/item-select-modal.tsx | 2 +- .../integration/integration-select-modal.tsx | 2 +- .../modals-collection/src/apps/app-select-modal.tsx | 2 +- packages/ui/src/theme.ts | 12 +++++++++++- 4 files changed, 14 insertions(+), 4 deletions(-) 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 ee8b05b58..dc50bf300 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -70,7 +70,7 @@ export const ItemSelectModal = createModal(({ actions }) => { ); }).withOptions({ defaultTitle: (t) => t("item.create.title"), - size: 1200, + size: "xxl", }); const WidgetItem = ({ diff --git a/apps/nextjs/src/components/integration/integration-select-modal.tsx b/apps/nextjs/src/components/integration/integration-select-modal.tsx index c93f1d5de..5e99548cd 100644 --- a/apps/nextjs/src/components/integration/integration-select-modal.tsx +++ b/apps/nextjs/src/components/integration/integration-select-modal.tsx @@ -52,5 +52,5 @@ export const IntegrationSelectModal = createModal(( return ; }).withOptions({ defaultTitle: (t) => t("integration.action.create"), - size: 1200, + size: "xxl", }); diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx index a4f37fcb7..fa00c92a8 100644 --- a/packages/modals-collection/src/apps/app-select-modal.tsx +++ b/packages/modals-collection/src/apps/app-select-modal.tsx @@ -151,5 +151,5 @@ export const AppSelectModal = createModal(({ actions, inner ); }).withOptions({ defaultTitle: (t) => t("app.action.select.title"), - size: 1200, + size: "xxl", }); diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts index e1e928d57..2d5db6f0d 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -1,4 +1,4 @@ -import { createTheme } from "@mantine/core"; +import { createTheme, Modal } from "@mantine/core"; import { primaryColor } from "./theme/colors/primary"; import { secondaryColor } from "./theme/colors/secondary"; @@ -9,4 +9,14 @@ export const theme = createTheme({ secondaryColor, }, primaryColor: "primaryColor", + components: { + Modal: Modal.extend({ + vars: (_theme, props) => { + if (props.size === "xxl") { + return { root: { "--modal-size": "75rem" } }; + } + return { root: {} }; + }, + }), + }, }); From 947504a16e0fc96e681bef4f4ecba6d808b796b1 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 16:54:22 +0200 Subject: [PATCH 14/53] fix(onboarding): replace stepper with progress bar and fix finish flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Mantine Stepper with a Progress bar + current integration avatar/name. The stepper broke layout with 10+ integrations on mobile. - Remove useEffect for setupIntegrations — call it directly when advancing past the last integration or clicking skip, avoiding double-call risks (cf. Cloudflare outage pattern). - Call router.refresh() after setupIntegrations so the server-rendered InitFinish component renders properly with login links. - Remove DB query from init-finish.tsx — redirect to root path instead of looking up an arbitrary board name. --- .../init/_steps/finish/init-finish.tsx | 10 +- .../_steps/integrations/init-integrations.tsx | 141 +++++------------- 2 files changed, 38 insertions(+), 113 deletions(-) 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 7e426bcfb..3c4b41012 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 @@ -4,27 +4,21 @@ import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-r 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"; export const InitFinish = async () => { - const firstBoard = await db.query.boards.findFirst({ columns: { name: true } }); const tFinish = await getScopedI18n("init.step.finish"); - const boardName = firstBoard?.name ?? "dashboard"; return ( {tFinish("description")} - - {tFinish("action.goToBoard", { name: boardName })} + + {tFinish("action.goToBoard", { name: "dashboard" })} {isProviderEnabled("credentials") && ( 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 index 98c4976a4..1f2116b8d 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx @@ -1,15 +1,15 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Button, Card, Center, Group, Loader, Stack, Stepper, Text } from "@mantine/core"; -import { IconArrowRight, IconBook2, IconCheck, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react"; +import { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button, Card, Center, Group, Loader, Progress, Stack, Text } from "@mantine/core"; +import { IconArrowRight } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import { getMantineColor } from "@homarr/common"; import type { IntegrationKind } from "@homarr/definitions"; -import { createDocumentationLink, getIntegrationName } from "@homarr/definitions"; +import { getIntegrationName } from "@homarr/definitions"; import { useScopedI18n } from "@homarr/translation/client"; -import { IntegrationAvatar, Link } from "@homarr/ui"; +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"; @@ -21,6 +21,7 @@ export const InitIntegrations = () => { const [selectedKinds, setSelectedKinds] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const t = useScopedI18n("init.step.integrations"); + const router = useRouter(); const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation(); @@ -33,13 +34,15 @@ export const InitIntegrations = () => { setPhase("configure"); }; - const handleSkipAll = () => { + const finishSetupAsync = async () => { setPhase("done"); + await setupIntegrations(); + router.refresh(); }; const advanceOrFinish = () => { if (currentIndex + 1 >= selectedKinds.length) { - setPhase("done"); + void finishSetupAsync(); } else { setCurrentIndex((i) => i + 1); } @@ -51,44 +54,43 @@ export const InitIntegrations = () => { }; if (phase === "done") { - return ; + return ( + +
+ + + {t("configure.finishing")} + +
+
+ ); } if (phase === "configure") { - const currentKind = selectedKinds[currentIndex]; - if (currentKind === undefined) { - return ( - -
- -
-
- ); - } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds + const currentKind = selectedKinds[currentIndex]!; + const progress = ((currentIndex + 1) / selectedKinds.length) * 100; return ( - - {selectedKinds.map((kind, index) => ( - } - completedIcon={} - label={index === currentIndex ? getIntegrationName(kind) : undefined} - /> - ))} - - - - {t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })} - + + + + {getIntegrationName(currentKind)} + + + + {t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })} + + @@ -103,7 +105,7 @@ export const InitIntegrations = () => { - - - - - -
-
- ); -}; From 97e39ced44a476ddc026da49917dc62a8ce459ee Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 16:54:31 +0200 Subject: [PATCH 15/53] refactor(integrations): make onSuccess required and simplify new page - Make onSuccess a required prop on NewIntegrationForm so the consumer always controls post-creation behavior. Remove internal router.push. - Add IntegrationNewFormWrapper client component to handle navigation for the server-rendered manage page. - Replace IntegrationPickerPage with a simple "back to overview" button when an invalid integration kind is provided. Delete unused integration-picker-page.tsx. --- .../new/_integration-new-form-wrapper.tsx | 26 +++++ .../new/_integration-new-form.tsx | 102 ++++++++++-------- .../[locale]/manage/integrations/new/page.tsx | 31 ++++-- .../integration/integration-picker-page.tsx | 25 ----- 4 files changed, 101 insertions(+), 83 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx delete mode 100644 apps/nextjs/src/components/integration/integration-picker-page.tsx diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx new file mode 100644 index 000000000..7d7768ec2 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form-wrapper.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import type { IntegrationKind } from "@homarr/definitions"; + +import { NewIntegrationForm } from "./_integration-new-form"; + +interface IntegrationNewFormWrapperProps { + kind: IntegrationKind; + initialUrl?: string; + initialName?: string; +} + +export const IntegrationNewFormWrapper = ({ kind, initialUrl, initialName }: IntegrationNewFormWrapperProps) => { + const router = useRouter(); + + return ( + router.push("/manage/integrations")} + /> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index f0577bd76..04d0f8b14 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -1,7 +1,6 @@ "use client"; import { startTransition, useState } from "react"; -import { useRouter } from "next/navigation"; import { Alert, Button, @@ -48,8 +47,9 @@ interface NewIntegrationFormProps { kind: IntegrationKind; initialUrl?: string; initialName?: string; - onSuccess?: () => void; + onSuccess: () => void; onCancel?: () => void; + isOnboarding?: boolean; } const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( @@ -60,13 +60,19 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( }), ); -export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, onCancel }: NewIntegrationFormProps) => { +export const NewIntegrationForm = ({ + kind, + initialUrl, + initialName, + onSuccess, + onCancel, + isOnboarding = false, +}: NewIntegrationFormProps) => { const t = useI18n(); const secretKinds = getAllSecretKindOptions(kind); const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url")); - const router = useRouter(); const { data: session } = useSession(); - const canCreateApps = session?.user.permissions.includes("app-create") ?? false; + const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false); let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? ""; if (hasUrlSecret) { @@ -80,18 +86,21 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o kind, value: "", })), - attemptSearchEngineCreation: true, - hasApp: true, - appHref: url, + attemptSearchEngineCreation: !isOnboarding, + hasApp: !isOnboarding, + appHref: isOnboarding ? null : url, appId: null, }, }); - const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation({ + const { mutateAsync: createIntegrationAsync, isPending: isCreatePending } = clientApi.integration.create.useMutation({ async onSuccess() { await revalidatePathActionAsync("/manage/integrations"); }, }); + const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } = + clientApi.onboard.createIntegration.useMutation(); + const isPending = isCreatePending || isOnboardingCreatePending; const [error, setError] = useState(null); const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => { @@ -99,6 +108,39 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin : values.url; + const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => { + if (data && "error" in data && data.error) { + setError(data.error); + showErrorNotification({ + title: t("integration.page.create.notification.error.title"), + message: t("integration.page.create.notification.error.message"), + }); + return; + } + + showSuccessNotification({ + title: t("integration.page.create.notification.success.title"), + message: t("integration.page.create.notification.success.message"), + }); + + onSuccess(); + }; + + const onMutationError = () => { + showErrorNotification({ + title: t("integration.page.create.notification.error.title"), + message: t("integration.page.create.notification.error.message"), + }); + }; + + if (isOnboarding) { + await createOnboardingIntegrationAsync( + { kind, name: values.name, url, secrets: values.secrets }, + { onSuccess: onMutationSuccess, onError: onMutationError }, + ); + return; + } + const hasCustomHref = appHref !== null && appHref.trim().length >= 1; const app = hasApp @@ -114,42 +156,8 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o : undefined; await createIntegrationAsync( - { - kind, - ...values, - url, - app, - }, - { - onSuccess(data) { - // We do it this way as we are unable to send a typesafe error through onError - if (data?.error) { - setError(data.error); - showErrorNotification({ - title: t("integration.page.create.notification.error.title"), - message: t("integration.page.create.notification.error.message"), - }); - return; - } - - showSuccessNotification({ - title: t("integration.page.create.notification.success.title"), - message: t("integration.page.create.notification.success.message"), - }); - - if (onSuccess) { - onSuccess(); - } else { - router.push("/manage/integrations"); - } - }, - onError: () => { - showErrorNotification({ - title: t("integration.page.create.notification.error.title"), - message: t("integration.page.create.notification.error.message"), - }); - }, - }, + { kind, ...values, url, app }, + { onSuccess: onMutationSuccess, onError: onMutationError }, ); }; @@ -185,7 +193,7 @@ export const NewIntegrationForm = ({ kind, initialUrl, initialName, onSuccess, o {error !== null && } - {supportsSearchEngine && ( + {!isOnboarding && supportsSearchEngine && ( )} - + {!isOnboarding && } {onCancel ? ( diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx index 803504329..3aeb2057c 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -1,16 +1,15 @@ import { notFound } from "next/navigation"; -import { Container, Group, Stack, Title } from "@mantine/core"; +import { Button, Center, Container, Group, Stack, Text, Title } from "@mantine/core"; +import { IconArrowLeft } from "@tabler/icons-react"; import { z } from "zod/v4"; import { auth } from "@homarr/auth/next"; import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; -import { IntegrationAvatar } from "@homarr/ui"; +import { IntegrationAvatar, Link } from "@homarr/ui"; -import { IntegrationPickerPage } from "~/components/integration/integration-picker-page"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { env } from "~/env"; -import { NewIntegrationForm } from "./_integration-new-form"; +import { IntegrationNewFormWrapper } from "./_integration-new-form-wrapper"; interface NewIntegrationPageProps { searchParams: Promise<{ @@ -34,11 +33,21 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps return ( <> - - - {t("action.create")} - - + +
+ + {t("action.create")} + {t("page.list.search")} + + +
); @@ -57,7 +66,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps {tCreate("title", { name: getIntegrationName(currentKind) })}
- + diff --git a/apps/nextjs/src/components/integration/integration-picker-page.tsx b/apps/nextjs/src/components/integration/integration-picker-page.tsx deleted file mode 100644 index cd4618ef7..000000000 --- a/apps/nextjs/src/components/integration/integration-picker-page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { useRouter } from "next/navigation"; - -import type { IntegrationKind } from "@homarr/definitions"; - -import { IntegrationSelectGrid } from "./integration-select-grid"; - -interface IntegrationPickerPageProps { - enableMockIntegration: boolean; -} - -export const IntegrationPickerPage = ({ enableMockIntegration }: IntegrationPickerPageProps) => { - const router = useRouter(); - - const handleSelect = useCallback( - (kind: IntegrationKind) => { - router.push(`/manage/integrations/new?kind=${kind}`); - }, - [router], - ); - - return ; -}; From bef275804cc982fc753710742125cce0796c02f8 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 17:00:20 +0200 Subject: [PATCH 16/53] fix(api): refactor onboard-router widget placement and step advancement - Extract placeWidgetAsync into a standalone function with a PlaceWidgetContext parameter for clearer state management. - Fix widget skip logic: use config?.skip instead of !config || config.skip so integration-specific widgets without an explicit defaultWidgetConfigs entry are still placed with default 2x2 sizes. - Add createIntegration mutation for onboarding (no app linking or search engine creation needed during init). - Fix nextOnboardingStepAsync call: pass undefined instead of "start" to advance naturally without resetting to the beginning. - Add early return when no board is found in setupIntegrations. --- .../api/src/router/onboard/onboard-router.ts | 306 +++++++++++------- 1 file changed, 198 insertions(+), 108 deletions(-) diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index 75cffb576..327a8f961 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -2,26 +2,97 @@ import superjson from "superjson"; import { z } from "zod/v4"; import { createId } from "@homarr/common"; +import { encryptSecret } from "@homarr/common/server"; +import type { Database } from "@homarr/db"; import { eq } from "@homarr/db"; -import { groups, integrationItems, itemLayouts, items, onboarding } from "@homarr/db/schema"; +import { + groups, + integrationItems, + integrations, + integrationSecrets, + itemLayouts, + items, + onboarding, +} from "@homarr/db/schema"; import { defaultBookmarkApps, defaultWidgetConfigs, emptySuperJSON, everyoneGroup, getWidgetKindsForIntegration, + integrationKinds, + integrationSecretKinds, onboardingSteps, } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions"; import { zodEnumFromArray } from "@homarr/validation/enums"; import { createTRPCRouter, onboardingProcedure, publicProcedure } from "../../trpc"; +import { MissingSecretError, testConnectionAsync } from "../integration/integration-test-connection"; +import { mapTestConnectionError } from "../integration/map-test-connection-error"; import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries"; const widgetConfigMap = new Map(defaultWidgetConfigs.map((config) => [config.kind, config])); const getWidgetConfig = (kind: WidgetKind) => widgetConfigMap.get(kind); +interface PlaceWidgetContext { + db: Database; + boardId: string; + sectionId: string; + layoutId: string; + columnCount: number; + xOffset: number; + yOffset: number; + rowMaxHeight: number; +} + +const placeWidgetAsync = async ( + ctx: PlaceWidgetContext, + 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 ctx.db.insert(items).values({ + id: itemId, + boardId: ctx.boardId, + kind, + options: options ?? emptySuperJSON, + advancedOptions: emptySuperJSON, + }); + + await ctx.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 ctx.db.insert(integrationItems).values({ + itemId, + integrationId, + }); + } + + ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight); + ctx.xOffset += itemWidth; +}; + export const onboardRouter = createTRPCRouter({ currentStep: publicProcedure.query(async ({ ctx }) => { return await getOnboardingOrFallbackAsync(ctx.db); @@ -29,6 +100,7 @@ export const onboardRouter = createTRPCRouter({ nextStep: publicProcedure .input( z.object({ + // Preferred step is only needed for 'preferred' conditions preferredStep: zodEnumFromArray(onboardingSteps).optional(), }), ) @@ -47,6 +119,61 @@ export const onboardRouter = createTRPCRouter({ step: "start", }); }), + createIntegration: onboardingProcedure + .requiresStep("integrations") + .input( + z.object({ + name: z.string().nonempty().max(127), + url: z + .string() + .url() + .regex(/^https?:\/\//), + 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) { + return { success: false as const, error }; + } + throw error; + }); + + if (!result.success) { + return { error: mapTestConnectionError(result.error) }; + } + + const integrationId = createId(); + await ctx.db.insert(integrations).values({ + id: integrationId, + name: input.name, + url: input.url, + kind: input.kind, + appId: null, + }); + + if (input.secrets.length >= 1) { + await ctx.db.insert(integrationSecrets).values( + input.secrets.map((secret) => ({ + kind: secret.kind, + value: encryptSecret(secret.value), + integrationId, + })), + ); + } + }), setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => { const db = ctx.db; @@ -60,123 +187,86 @@ export const onboardRouter = createTRPCRouter({ }, }); - if (board) { - 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)); + 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 widgetCtx: PlaceWidgetContext = { + db, + boardId: board.id, + sectionId: section.id, + layoutId: layout.id, + columnCount: layout.columnCount, + 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 = getWidgetConfig(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( + widgetCtx, + widgetKind, + matchingIds, + options, + config && { + width: config.width, + height: config.height, + }, + ); + } } - const section = board.sections.find((boardSection) => boardSection.kind === "empty"); - const layout = board.layouts[0]; + for (const app of allApps) { + const appOptions = superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }); + await placeWidgetAsync(widgetCtx, "app", [], appOptions, { width: 1, height: 1 }); + } - if (section && layout) { - const columnCount = layout.columnCount; - let xOffset = 0; - let yOffset = 0; - let rowMaxHeight = 0; + const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name)); + const bookmarkAppIds = allApps.filter((app) => bookmarkAppNames.has(app.name)).map((app) => app.id); - const placeWidgetAsync = async ( - kind: WidgetKind, - linkedIntegrationIds: string[], - options?: string, - size?: { width: number; height: number }, - ) => { - const itemWidth = size?.width ?? 2; - const itemHeight = size?.height ?? 2; + for (const config of defaultWidgetConfigs) { + if (config.skip) continue; + if (placedWidgets.has(config.kind)) continue; + placedWidgets.add(config.kind); - if (xOffset + itemWidth > columnCount) { - xOffset = 0; - yOffset += rowMaxHeight; - rowMaxHeight = 0; - } + let options = config.options ? { ...config.options } : undefined; - const itemId = createId(); - await db.insert(items).values({ - id: itemId, - boardId: board.id, - kind, - options: options ?? emptySuperJSON, - advancedOptions: emptySuperJSON, - }); - - await db.insert(itemLayouts).values({ - itemId, - sectionId: section.id, - layoutId: layout.id, - xOffset, - yOffset, - width: itemWidth, - height: itemHeight, - }); - - for (const integrationId of linkedIntegrationIds) { - await db.insert(integrationItems).values({ - itemId, - integrationId, - }); - } - - rowMaxHeight = Math.max(rowMaxHeight, itemHeight); - xOffset += itemWidth; - }; - - const placedWidgets = new Set(); - - // Place integration-specific widgets - for (const integration of allIntegrations) { - const kind = integration.kind; - - for (const widgetKind of getWidgetKindsForIntegration(kind)) { - if (placedWidgets.has(widgetKind)) continue; - const config = getWidgetConfig(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( - widgetKind, - matchingIds, - options, - config && { width: config.width, height: config.height }, - ); - } + if (config.kind === "bookmarks" && bookmarkAppIds.length > 0) { + options = { ...options, items: bookmarkAppIds }; } - // Place an app widget for each app in the DB - for (const app of allApps) { - const appOptions = superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }); - await placeWidgetAsync("app", [], appOptions, { width: 1, height: 1 }); - } - - // Place general widgets from config - 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) continue; - if (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(config.kind, [], options ? superjson.stringify(options) : undefined, { - width: config.width, - height: config.height, - }); - } + await placeWidgetAsync(widgetCtx, config.kind, [], options ? superjson.stringify(options) : undefined, { + width: config.width, + height: config.height, + }); } } - await nextOnboardingStepAsync(db, "start"); + await nextOnboardingStepAsync(db, undefined); }), }); From 904bef50d850a87fbeb7b969b39f47ae947bcf7d Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 17:00:26 +0200 Subject: [PATCH 17/53] fix(ui): use inline vars resolver instead of Modal.extend Modal.extend() is not available at module evaluation time in the Next.js/Turbopack ESM context, causing a runtime TypeError. Define the vars resolver as a plain object in createTheme's components key. --- packages/ui/src/theme.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts index 2d5db6f0d..090e0d43c 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -1,4 +1,4 @@ -import { createTheme, Modal } from "@mantine/core"; +import { createTheme } from "@mantine/core"; import { primaryColor } from "./theme/colors/primary"; import { secondaryColor } from "./theme/colors/secondary"; @@ -10,13 +10,13 @@ export const theme = createTheme({ }, primaryColor: "primaryColor", components: { - Modal: Modal.extend({ - vars: (_theme, props) => { + Modal: { + vars: (_theme: unknown, props: { size?: string }) => { if (props.size === "xxl") { return { root: { "--modal-size": "75rem" } }; } return { root: {} }; }, - }), + }, }, }); From c7300aee76a21032de393fd861eb40940fffcbbf Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 18:06:41 +0200 Subject: [PATCH 18/53] fix(onboarding): preserve selected integrations when navigating back The multi-select grid initializes its own internal Set on mount, so returning from the configure phase to the select phase lost all selections. Accept an initialSelection prop and pass the parent's selectedKinds to restore state on re-mount. --- .../[locale]/init/_steps/integrations/init-integrations.tsx | 2 +- .../components/integration/integration-multi-select-grid.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 index 1f2116b8d..27c42fca6 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx @@ -102,7 +102,7 @@ export const InitIntegrations = () => { {t("description")} - + )} + {onSkip && ( + + )} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 37e2ae207..f5abf6544 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1109,6 +1109,7 @@ "add": "Add", "apply": "Apply", "backToOverview": "Back to overview", + "skip": "Skip", "change": "Change", "create": "Create", "createAnother": "Create and start over", From 16091ffd0297308f6d94f4f125d31c5b18d258b2 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 18:08:59 +0200 Subject: [PATCH 20/53] fix(onboarding): use consistent card width across all integration phases The select, configure, and done phases each had different hardcoded widths causing a jarring layout shift when transitioning between them. Unify all three to a single CARD_WIDTH constant. --- .../init/_steps/integrations/init-integrations.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 index 11363f2b2..f57abca4e 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx @@ -16,6 +16,8 @@ import { IntegrationMultiSelectGrid } from "~/components/integration/integration type Phase = "select" | "configure" | "done"; +const CARD_WIDTH = 64 * 16; + export const InitIntegrations = () => { const [phase, setPhase] = useState("select"); const [selectedKinds, setSelectedKinds] = useState([]); @@ -55,7 +57,7 @@ export const InitIntegrations = () => { if (phase === "done") { return ( - +
@@ -72,7 +74,7 @@ export const InitIntegrations = () => { const progress = ((currentIndex + 1) / selectedKinds.length) * 100; return ( - + @@ -99,7 +101,7 @@ export const InitIntegrations = () => { } return ( - + {t("description")} From c8ff718778fdbed3d0a04f113d47d17537b4d441 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 18:16:29 +0200 Subject: [PATCH 21/53] fix(definitions): skip timetable widget from default onboarding dashboard The SBB timetable widget is too niche to include by default during onboarding. Add it back to defaultWidgetConfigs with skip: true so it won't be placed automatically. --- packages/definitions/src/widget-integration-map.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index 7cbff61de..e2d34850c 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -59,7 +59,8 @@ export interface DefaultWidgetConfig { 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: 1, options: { title: "Useful Links", layout: "grid", openNewTab: true } }, + { kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } }, + { kind: "timetable", width: 2, height: 2, skip: true }, ]; export const generalWidgets: WidgetKind[] = defaultWidgetConfigs @@ -95,6 +96,6 @@ export const defaultBookmarkApps = [ { name: "Support Homarr", href: "https://opencollective.com/homarr", - iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/open-collective.svg", + iconUrl: "https://avatars.githubusercontent.com/u/13403593?v=4", }, ]; From 8de443768e8053be2f876b1e441ba455b3d6adb5 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 18:18:16 +0200 Subject: [PATCH 22/53] fix(ui): use default variant for skip button in integration form Subtle variant was too easy to miss. Switch to default variant for better visibility alongside the other action buttons. --- .../[locale]/manage/integrations/new/_integration-new-form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index afcbc0a3a..64f742fff 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -218,7 +218,7 @@ export const NewIntegrationForm = ({ )} {onSkip && ( - )} From 6364e4181b40df1511655c5cd8829970967fa676 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 14 May 2026 18:20:19 +0200 Subject: [PATCH 23/53] fix(onboarding): add error recovery for setupIntegrations failure If setupIntegrations rejects, fall back to the select phase instead of leaving the user stuck on an infinite loading screen. Also fix OpenCollective bookmark icon to use dashboard-icons CDN SVG for consistency with other bookmark icons. --- .../init/_steps/integrations/init-integrations.tsx | 8 ++++++-- packages/definitions/src/widget-integration-map.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) 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 index f57abca4e..b0caa7189 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx @@ -38,8 +38,12 @@ export const InitIntegrations = () => { const finishSetupAsync = async () => { setPhase("done"); - await setupIntegrations(); - router.refresh(); + try { + await setupIntegrations(); + router.refresh(); + } catch { + setPhase("select"); + } }; const advanceOrFinish = () => { diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index e2d34850c..fb0a4f9f2 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -96,6 +96,6 @@ export const defaultBookmarkApps = [ { name: "Support Homarr", href: "https://opencollective.com/homarr", - iconUrl: "https://avatars.githubusercontent.com/u/13403593?v=4", + iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/open-collective.svg", }, ]; From 545a10ab20580ce67b72148d55b84cced6641e22 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 16 May 2026 11:39:43 +0200 Subject: [PATCH 24/53] fix: remove timetable from defaultWidgetConfigs --- packages/definitions/src/widget-integration-map.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index fb0a4f9f2..a88f9d99c 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -60,7 +60,6 @@ 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 } }, - { kind: "timetable", width: 2, height: 2, skip: true }, ]; export const generalWidgets: WidgetKind[] = defaultWidgetConfigs From 5bc88eb4ee259d7ff708b47ce844235ee3fa0aa0 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 18 May 2026 15:19:11 +0200 Subject: [PATCH 25/53] fix: MissingSecretError type mismatch in onboard createIntegration mutation --- packages/api/src/router/onboard/onboard-router.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index 327a8f961..daec79ada 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -27,6 +27,8 @@ import { import type { WidgetKind } from "@homarr/definitions"; import { zodEnumFromArray } from "@homarr/validation/enums"; +import { TRPCError } from "@trpc/server"; + import { createTRPCRouter, onboardingProcedure, publicProcedure } from "../../trpc"; import { MissingSecretError, testConnectionAsync } from "../integration/integration-test-connection"; import { mapTestConnectionError } from "../integration/map-test-connection-error"; @@ -145,10 +147,12 @@ export const onboardRouter = createTRPCRouter({ kind: input.kind, secrets: input.secrets, }).catch((error) => { - if (error instanceof MissingSecretError) { - return { success: false as const, error }; - } - throw error; + if (!(error instanceof MissingSecretError)) throw error; + + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.message, + }); }); if (!result.success) { From e714c9617211cbfb9252a57bf2bf82e30a70b975 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 18 May 2026 15:49:10 +0200 Subject: [PATCH 26/53] chore: fix formatting --- packages/api/src/router/onboard/onboard-router.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index daec79ada..e5535067b 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -1,3 +1,4 @@ +import { TRPCError } from "@trpc/server"; import superjson from "superjson"; import { z } from "zod/v4"; @@ -27,8 +28,6 @@ import { import type { WidgetKind } from "@homarr/definitions"; import { zodEnumFromArray } from "@homarr/validation/enums"; -import { TRPCError } from "@trpc/server"; - import { createTRPCRouter, onboardingProcedure, publicProcedure } from "../../trpc"; import { MissingSecretError, testConnectionAsync } from "../integration/integration-test-connection"; import { mapTestConnectionError } from "../integration/map-test-connection-error"; From 1fcc149c911f36d94f4cf7eb994b16c93bc2306c Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 25 May 2026 12:42:55 +0200 Subject: [PATCH 27/53] feat(seed): add demo user seeding with pre-placed widgets on dashboard board - seedBoardWidgetsAsync always places widgets (clock, weather, bookmarks, integration-linked, app widgets) on the default board - seedDemoUserAsync creates demo user + admin group, skips onboarding, reuses the existing dashboard board instead of creating a duplicate - Fix broken open-collective icon URL in defaultBookmarkApps --- packages/db/migrations/seed.ts | 156 +++++++++++++++--- .../definitions/src/widget-integration-map.ts | 2 +- 2 files changed, 135 insertions(+), 23 deletions(-) diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 2a657b8fe..ceb269a3c 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -1,14 +1,20 @@ +import superjson from "superjson"; + import { createId, objectKeys } from "@homarr/common"; import { createDocumentationLink, credentialsAdminGroup, defaultBookmarkApps, + defaultWidgetConfigs, + emptySuperJSON, everyoneGroup, getIntegrationDefaultUrl, getIntegrationName, + getWidgetKindsForIntegration, integrationDefs, integrationKinds, } from "@homarr/definitions"; +import type { WidgetKind } from "@homarr/definitions"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import type { Database } from ".."; @@ -25,7 +31,10 @@ import { groupMembers, groupPermissions, groups, + integrationItems, integrations, + itemLayouts, + items, layouts, onboarding, searchEngines, @@ -42,6 +51,7 @@ export const seedDataAsync = async (db: Database) => { await seedDefaultIntegrationsAsync(db); await seedDefaultAppsAsync(db); await seedDefaultBoardAsync(db); + await seedBoardWidgetsAsync(db); if (process.env.DEMO_MODE === "true") { await seedDemoUserAsync(db); @@ -312,30 +322,132 @@ const seedDemoUserAsync = async (db: Database) => { previousStep: "settings", }); - const boardId = createId(); - await db.insert(boards).values({ - id: boardId, - name: "default", - isPublic: false, - creatorId: userId, - }); - await db.insert(sections).values({ - id: createId(), - kind: "empty", - xOffset: 0, - yOffset: 0, - boardId, - }); - await db.insert(layouts).values({ - id: createId(), - name: "Base", - columnCount: 12, - breakpoint: 0, - boardId, - }); - await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId)); + const board = await db.query.boards.findFirst(); + if (board) { + await db.update(users).set({ homeBoardId: board.id }).where(eq(users.id, userId)); + } console.log( "Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.", ); }; + +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 ctx = { + xOffset: 0, + yOffset: 0, + rowMaxHeight: 0, + columnCount: layout.columnCount, + }; + + const placedWidgets = new Set(); + const widgetConfigMap = new Map(defaultWidgetConfigs.map((cfg) => [cfg.kind, cfg])); + + const placeWidgetAsync = async ( + kind: WidgetKind, + linkedIntegrationIds: string[], + options?: string, + size?: { width: number; height: number }, + ) => { + const width = size?.width ?? 2; + const height = size?.height ?? 2; + + if (ctx.xOffset + width > ctx.columnCount) { + ctx.xOffset = 0; + ctx.yOffset += ctx.rowMaxHeight; + ctx.rowMaxHeight = 0; + } + + const itemId = createId(); + await db.insert(items).values({ + id: itemId, + boardId: board.id, + kind, + options: options ?? emptySuperJSON, + advancedOptions: emptySuperJSON, + }); + + await db.insert(itemLayouts).values({ + itemId, + sectionId: section.id, + layoutId: layout.id, + xOffset: ctx.xOffset, + yOffset: ctx.yOffset, + width, + height, + }); + + for (const integrationId of linkedIntegrationIds) { + await db.insert(integrationItems).values({ itemId, integrationId }); + } + + ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, height); + ctx.xOffset += width; + }; + + 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( + widgetKind, + matchingIds, + options, + config && { width: config.width, height: config.height }, + ); + } + } + + for (const app of allApps) { + await placeWidgetAsync("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(config.kind, [], options ? superjson.stringify(options) : undefined, { + width: config.width, + height: config.height, + }); + } + + console.log(`Placed ${placedWidgets.size + allApps.length} widgets on board`); +}; diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index a88f9d99c..8bd4d7734 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -95,6 +95,6 @@ export const defaultBookmarkApps = [ { name: "Support Homarr", href: "https://opencollective.com/homarr", - iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/open-collective.svg", + iconUrl: "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets@latest/assets/opencollective.svg", }, ]; From 309ba0590d50f5aff2b962f326af052b9d5022cf Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Wed, 27 May 2026 14:36:04 +0200 Subject: [PATCH 28/53] feat(onboarding): enhance integration setup with Docker discovery, URL templating, and API key helpers Add automatic Docker container discovery during onboarding to pre-select and pre-fill integrations. Introduce base URL templating with subdomain/port modes, API key settings path links, and a curated onboarding list that hides niche integrations (GitHub, npm, Docker Hub, etc.). Prevent duplicate widget seeding and remove the timetable widget from defaults. --- .../integrations/docker-discovery-panel.tsx | 152 ++++++++++ .../_steps/integrations/init-integrations.tsx | 281 ++++++++++++++++-- .../new/_integration-new-form.tsx | 52 +++- .../integration/integration-grid-shared.ts | 16 +- .../integration-multi-select-grid.tsx | 11 +- .../integration/integration-select-grid.tsx | 2 +- .../api/src/router/onboard/onboard-router.ts | 154 +++++++++- .../src/docker-integration-match.ts | 91 ++++++ packages/definitions/src/index.ts | 2 + .../src/integration-url-template.ts | 30 ++ packages/definitions/src/integration.ts | 65 +++- .../definitions/src/test/api-key-url.spec.ts | 31 ++ .../src/test/docker-integration-match.spec.ts | 119 ++++++++ .../src/test/integration-url-template.spec.ts | 40 +++ .../definitions/src/widget-integration-map.ts | 13 + packages/request-handler/src/docker.ts | 3 +- packages/translation/src/lang/en-gb.json | 25 +- packages/translation/src/lang/en.json | 25 +- 18 files changed, 1056 insertions(+), 56 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx 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 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..83320184c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/docker-discovery-panel.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + Avatar, + Badge, + Checkbox, + Collapse, + Divider, + Group, + Paper, + Stack, + Text, + ThemeIcon, + UnstyledButton, +} from "@mantine/core"; +import { IconChevronDown, IconBrandDocker } 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; + iconUrl: string | null; +} + +interface DockerDiscoveryIndicatorProps { + integrations: DiscoveredIntegration[]; + apps: DiscoveredApp[]; + appsEnabled: boolean; + onToggleAllApps: (checked: boolean) => void; +} + +export const DockerDiscoveryIndicator = ({ + integrations, + apps, + appsEnabled, + onToggleAllApps, +}: DockerDiscoveryIndicatorProps) => { + const hasApps = apps.length > 0; + const [expanded, setExpanded] = useState(false); + + const expandedOnce = useRef(false); + useEffect(() => { + if (hasApps && !expandedOnce.current) { + expandedOnce.current = true; + setExpanded(true); + } + }, [hasApps]); + const t = useScopedI18n("init.step.integrations.docker"); + const totalCount = integrations.length + apps.length; + + if (totalCount === 0) return null; + + return ( + + setExpanded((v) => !v)} w="100%"> + + + + + + + + {t("detected", { count: String(totalCount) })} + + + {t("detectedBreakdown", { + integrations: String(integrations.length), + apps: String(apps.length), + })} + + + + + + + + + + {integrations.map((integration) => ( + + + + {integration.containerName} + + + {getIntegrationName(integration.kind)} + + + ))} + + {hasApps && ( + <> + {integrations.length > 0 && } + onToggleAllApps(!appsEnabled)} + styles={{ label: { fontSize: "var(--mantine-font-size-xs)" } }} + /> + {appsEnabled && apps.map((app) => ( + + + {app.containerName.at(0)?.toUpperCase()} + + + {app.containerName} + + + ))} + + )} + + + + ); +}; 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 index b0caa7189..f6302e5ab 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/integrations/init-integrations.tsx @@ -1,37 +1,118 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { Button, Card, Center, Group, Loader, Progress, Stack, Text } from "@mantine/core"; -import { IconArrowRight } from "@tabler/icons-react"; +import { + ActionIcon, + Button, + Card, + Center, + Collapse, + Group, + Loader, + Progress, + SegmentedControl, + Stack, + Text, + TextInput, + Tooltip, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconArrowRight, IconSettings } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import type { IntegrationKind } from "@homarr/definitions"; -import { getIntegrationName } from "@homarr/definitions"; +import { buildIntegrationUrl, getIntegrationName } from "@homarr/definitions"; +import type { UrlTemplateMode } from "@homarr/definitions"; 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 { DockerDiscoveryIndicator } from "./docker-discovery-panel"; + type Phase = "select" | "configure" | "done"; +interface IntegrationDraft { + kind: IntegrationKind; + initialUrl: string; + initialName?: string; + dockerPort?: number | null; + source: "manual" | "docker"; +} + const CARD_WIDTH = 64 * 16; 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 } = clientApi.onboard.discoverDockerServices.useQuery(undefined, { + retry: false, + refetchOnWindowFocus: false, + }); + + const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : []; + const dockerApps = dockerData?.status === "success" ? dockerData.apps : []; + + const [addDockerApps, setAddDockerApps] = useState(true); + + const dockerApplied = useRef(false); + useEffect(() => { + if (!dockerData || 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)); + }, [dockerData]); const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => { setSelectedKinds(kinds); }, []); 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"); }; @@ -39,6 +120,15 @@ export const InitIntegrations = () => { const finishSetupAsync = async () => { setPhase("done"); try { + if (addDockerApps && dockerApps.length > 0) { + await createApps( + dockerApps.map((app) => ({ + name: app.containerName, + href: app.suggestedUrl || null, + iconUrl: app.iconUrl, + })), + ); + } await setupIntegrations(); router.refresh(); } catch { @@ -73,34 +163,19 @@ export const InitIntegrations = () => { } if (phase === "configure") { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds - const currentKind = selectedKinds[currentIndex]!; - const progress = ((currentIndex + 1) / selectedKinds.length) * 100; - return ( - - - - - - {getIntegrationName(currentKind)} - - - - {t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })} - - - - - - + ); } @@ -109,7 +184,39 @@ export const InitIntegrations = () => { {t("description")} - + + + + {t("urlTemplate.label")} + + 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("sonarr", baseHost, urlMode) }) + : t("urlTemplate.hint")} + + + + diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index bf78f068b..a545a59cf 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -125,7 +125,9 @@ "detected": "Docker detected {count} services", "detectedBreakdown": "{integrations} integrations · {apps} apps", "appBadge": "App", - "appSectionLabel": "Apps to add to dashboard ({count} selected)" + "appSectionLabel": "Apps to add to dashboard ({count} selected)", + "scanning": "Scanning Docker...", + "scanningDescription": "Looking for running containers on your system" } }, "finish": { From 0efbe8f6a36fdd6c2019af5175842380505fb32f Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Wed, 27 May 2026 17:28:55 +0200 Subject: [PATCH 40/53] style: format onboard-router with oxfmt --- .../api/src/router/onboard/onboard-router.ts | 210 +++++++++++------- 1 file changed, 124 insertions(+), 86 deletions(-) diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index 8c83ee980..b61652938 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -29,7 +29,10 @@ import type { IntegrationKind } from "@homarr/definitions"; import { zodEnumFromArray } from "@homarr/validation/enums"; import { createTRPCRouter, onboardingProcedure, publicProcedure } from "../../trpc"; -import { MissingSecretError, testConnectionAsync } from "../integration/integration-test-connection"; +import { + MissingSecretError, + testConnectionAsync, +} from "../integration/integration-test-connection"; import { mapTestConnectionError } from "../integration/map-test-connection-error"; import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries"; @@ -143,93 +146,119 @@ export const onboardRouter = createTRPCRouter({ ); } }), - 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; 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; + 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; + iconUrl: string | 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()); + try { + const { DockerSingleton, dockerLabels } = await import("@homarr/docker"); - 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, - iconUrl, - }); + const dockerInstances = DockerSingleton.getInstances(); + if (dockerInstances.length === 0) { + return { status: "empty" as const, ...emptyResult }; } - } - return { status: "success" as const, integrations: discoveredIntegrations, apps: discoveredApps }; - } catch { - return { status: "unavailable" 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, + iconUrl, + }); + } + } + + return { + status: "success" as const, + integrations: discoveredIntegrations, + apps: discoveredApps, + }; + } catch { + return { status: "unavailable" as const, ...emptyResult }; + } + }), createAppsFromDiscovery: onboardingProcedure .requiresStep("integrations") .input( @@ -243,7 +272,8 @@ export const onboardRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { if (input.length === 0) return; - const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg"; + 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(), @@ -276,7 +306,10 @@ export const onboardRouter = createTRPCRouter({ where: eq(groups.name, everyoneGroup), }); if (everyoneGroupRow && !everyoneGroupRow.homeBoardId) { - await db.update(groups).set({ homeBoardId: board.id }).where(eq(groups.id, everyoneGroupRow.id)); + await db + .update(groups) + .set({ homeBoardId: board.id }) + .where(eq(groups.id, everyoneGroupRow.id)); } const section = board.sections.find((boardSection) => boardSection.kind === "empty"); @@ -296,7 +329,12 @@ export const onboardRouter = createTRPCRouter({ await placeAllWidgetsAsync( db, - { boardId: board.id, sectionId: section.id, layoutId: layout.id, columnCount: layout.columnCount }, + { + boardId: board.id, + sectionId: section.id, + layoutId: layout.id, + columnCount: layout.columnCount, + }, allIntegrations, allApps, ); From 8cb3c9d12dc3b5f03150165b0541431d860602ba Mon Sep 17 00:00:00 2001 From: ajnart <49837342+ajnart@users.noreply.github.com> Date: Wed, 27 May 2026 15:32:21 +0000 Subject: [PATCH 41/53] style: auto-fix lint and format --- .../assertions/onboarding-assertions.ts | 2 +- .../api/src/router/onboard/onboard-router.ts | 209 ++++++++---------- 2 files changed, 95 insertions(+), 116 deletions(-) diff --git a/e2e/shared/assertions/onboarding-assertions.ts b/e2e/shared/assertions/onboarding-assertions.ts index 953ada190..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"; diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index b61652938..38d151ed7 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -29,10 +29,7 @@ import type { IntegrationKind } from "@homarr/definitions"; import { zodEnumFromArray } from "@homarr/validation/enums"; import { createTRPCRouter, onboardingProcedure, publicProcedure } from "../../trpc"; -import { - MissingSecretError, - testConnectionAsync, -} from "../integration/integration-test-connection"; +import { MissingSecretError, testConnectionAsync } from "../integration/integration-test-connection"; import { mapTestConnectionError } from "../integration/map-test-connection-error"; import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries"; @@ -146,119 +143,105 @@ export const onboardRouter = createTRPCRouter({ ); } }), - 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; - iconUrl: string | null; - }[], + 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; + 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; }; - try { - const { DockerSingleton, dockerLabels } = await import("@homarr/docker"); + 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 dockerInstances = DockerSingleton.getInstances(); - if (dockerInstances.length === 0) { - return { status: "empty" as const, ...emptyResult }; - } + const { url: suggestedUrl, publishedPort } = buildSuggestedUrl(container.Ports, container.host); - 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 kind = matchIntegrationKindFromContainer({ + image: container.Image, + name: containerName, + }); - 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, + iconUrl, }); - - 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, - iconUrl, - }); - } } - - return { - status: "success" as const, - integrations: discoveredIntegrations, - apps: discoveredApps, - }; - } catch { - return { status: "unavailable" as const, ...emptyResult }; } - }), + + return { + status: "success" as const, + integrations: discoveredIntegrations, + apps: discoveredApps, + }; + } catch { + return { status: "unavailable" as const, ...emptyResult }; + } + }), createAppsFromDiscovery: onboardingProcedure .requiresStep("integrations") .input( @@ -272,8 +255,7 @@ export const onboardRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { if (input.length === 0) return; - const defaultIcon = - "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg"; + 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(), @@ -306,10 +288,7 @@ export const onboardRouter = createTRPCRouter({ where: eq(groups.name, everyoneGroup), }); if (everyoneGroupRow && !everyoneGroupRow.homeBoardId) { - await db - .update(groups) - .set({ homeBoardId: board.id }) - .where(eq(groups.id, everyoneGroupRow.id)); + await db.update(groups).set({ homeBoardId: board.id }).where(eq(groups.id, everyoneGroupRow.id)); } const section = board.sections.find((boardSection) => boardSection.kind === "empty"); From d0f63b95633890a84fb815895feef97a3158d64f Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 28 May 2026 10:39:24 +0200 Subject: [PATCH 42/53] fix(seed): use 10 columns for default dashboard layout --- packages/db/migrations/seed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index ecbb923c8..a3cb84fab 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -257,7 +257,7 @@ const seedDefaultBoardAsync = async (db: Database) => { await db.insert(layouts).values({ id: createId(), name: "Base", - columnCount: 12, + columnCount: 10, breakpoint: 0, boardId, }); From 095d40b065e7f074b696436f8eaf36ebb510f3b1 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 28 May 2026 10:42:45 +0200 Subject: [PATCH 43/53] fix(ui): prevent item select grid from collapsing with few results --- apps/nextjs/src/components/board/items/item-select-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 678a276d8..acd2d7805 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -102,7 +102,7 @@ export const ItemSelectModal = createModal(({ actions }) => { }} /> - + {filteredItems.map((item) => ( handleAdd(item.kind)} /> ))} From 10c2e4217216143730d30f6339714463530765d4 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 28 May 2026 10:51:47 +0200 Subject: [PATCH 44/53] fix(ui): enforce minimum width on item select modal to prevent resizing --- apps/nextjs/src/components/board/items/item-select-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 acd2d7805..f1544f545 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -86,7 +86,7 @@ export const ItemSelectModal = createModal(({ actions }) => { }; return ( - + setSearch(event.currentTarget.value)} From 2268aa3d9956308fa7c0ab50d71858a9022a7c91 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 28 May 2026 10:54:32 +0200 Subject: [PATCH 45/53] fix(ui): enforce minimum width on all grid-based select modals Apply miw="min(1400px, 90vw)" to app select and integration select modals so they never shrink when filtering reduces visible items. --- .../src/components/integration/integration-select-grid.tsx | 2 +- packages/modals-collection/src/apps/app-select-modal.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx index 2d4410aa3..2a45500b7 100644 --- a/apps/nextjs/src/components/integration/integration-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx @@ -28,7 +28,7 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]); return ( - + setSearch(event.currentTarget.value)} diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx index fa00c92a8..f98833f14 100644 --- a/packages/modals-collection/src/apps/app-select-modal.tsx +++ b/packages/modals-collection/src/apps/app-select-modal.tsx @@ -78,7 +78,7 @@ export const AppSelectModal = createModal(({ actions, inner } return ( - + setSearch(event.currentTarget.value)} From 1065e30c8d2075da928056945eeb4c065e3d3702 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 28 May 2026 12:15:44 +0200 Subject: [PATCH 46/53] feat(init): unify onboarding step widths with shared layout Replace per-step hardcoded Card widths (384px, 776px, 1024px) with a consistent 1024px shell defined in _constants.ts. All steps now use an InitStepCard wrapper set to w=100%, inheriting width from the page shell. Also updates old-import cards to w=100% for proper width propagation. --- .../[locale]/init/_components/init-step-card.tsx | 12 ++++++++++++ apps/nextjs/src/app/[locale]/init/_constants.ts | 2 ++ .../[locale]/init/_steps/finish/init-finish.tsx | 8 +++++--- .../app/[locale]/init/_steps/group/init-group.tsx | 8 +++++--- .../[locale]/init/_steps/import/file-info-card.tsx | 8 +++++--- .../[locale]/init/_steps/import/init-import.tsx | 9 +++++---- .../init/_steps/settings/init-settings.tsx | 14 ++++++++------ .../app/[locale]/init/_steps/start/init-start.tsx | 7 ++++--- .../app/[locale]/init/_steps/user/init-user.tsx | 7 +++---- apps/nextjs/src/app/[locale]/init/page.tsx | 3 ++- .../src/components/initial-oldmarr-import.tsx | 2 +- .../components/initial/board-selection-card.tsx | 2 +- .../components/initial/import-settings-card.tsx | 2 +- .../src/components/initial/import-summary-card.tsx | 2 +- 14 files changed, 55 insertions(+), 31 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 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 477cf6d46..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,5 +1,5 @@ import type { MantineColor } from "@mantine/core"; -import { Button, Card, Stack, Text } from "@mantine/core"; +import { Button, Stack, Text } from "@mantine/core"; import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react"; import { isProviderEnabled } from "@homarr/auth/server"; @@ -9,11 +9,13 @@ 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 tFinish = await getScopedI18n("init.step.finish"); return ( - + {tFinish("description")} @@ -37,7 +39,7 @@ export const InitFinish = async () => { {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/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")} + + ); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx index 994d63b3c..038e8ef62 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/page.tsx @@ -1,5 +1,4 @@ import { Fragment } from "react"; -import type { PropsWithChildren } from "react"; import { redirect } from "next/navigation"; import { AccordionControl, @@ -8,12 +7,8 @@ import { ActionIcon, ActionIconGroup, Anchor, - Button, Divider, Group, - Menu, - MenuDropdown, - MenuTarget, Stack, Table, TableTbody, @@ -23,7 +18,7 @@ import { TableTr, Text, } from "@mantine/core"; -import { IconChevronDown, IconPencil, IconPlugX } from "@tabler/icons-react"; +import { IconPencil, IconPlugX } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; @@ -34,13 +29,11 @@ import { getIntegrationName } from "@homarr/definitions"; import { getScopedI18n } from "@homarr/translation/server"; import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui"; -import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; +import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { NoResults } from "~/components/no-results"; -import { env } from "~/env"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { DeleteIntegrationActionButton } from "./_integration-buttons"; -import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown"; import classes from "./page.module.css"; interface IntegrationsPageProps { @@ -67,13 +60,9 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) { title={t("page.list.title")} primaryAction={ canCreateIntegrations ? ( - - - - - - - + + {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 > - + )}