From 3cb29324747a85affb144f79fb171de0546298dd Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 4 Jun 2026 13:27:26 +0200 Subject: [PATCH] feat: redesign select modals, add log font-size slider, improve onboarding UX - Redesign widget/app/integration select modal cards: icon left of name, description tooltip on hover, "Add to board" button revealed on hover, reduced card height from 180 to 140 - Add client-side font-size slider (10-24px) to server logs terminal, persisted in localStorage - Merge Docker discovery into integration grid with "Detected via Docker" badges, remove separate DockerDiscoveryIndicator panel - Make base URL input more prominent (orange alert, auto-focused) in onboarding with stronger hint text - Add disableAutoFocus prop to SelectGridLayout --- .../_steps/integrations/init-integrations.tsx | 134 ++++++++++++++++-- .../manage/tools/logs/font-size-slider.tsx | 29 ++++ .../manage/tools/logs/log-context.tsx | 25 +++- .../app/[locale]/manage/tools/logs/page.tsx | 6 +- .../[locale]/manage/tools/logs/terminal.tsx | 16 ++- .../board/items/item-select-modal.tsx | 55 ++++--- .../integration-multi-select-grid.tsx | 36 +++-- .../integration/integration-select-grid.tsx | 17 ++- .../src/apps/app-select-modal.tsx | 89 ++++++++---- packages/translation/src/lang/en.json | 5 +- packages/ui/src/select-grid-layout.tsx | 6 +- 11 files changed, 331 insertions(+), 87 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/logs/font-size-slider.tsx 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 bc4590708..009206280 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,24 +1,36 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { ActionIcon, Alert, + Avatar, Button, + Card, Center, Collapse, Group, Loader, + Paper, Progress, SegmentedControl, + SimpleGrid, Stack, Text, TextInput, + ThemeIcon, Tooltip, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import { IconArrowRight, IconInfoCircle, IconSettings } from "@tabler/icons-react"; +import { + IconAlertTriangle, + IconArrowRight, + IconBrandDocker, + IconCheck, + IconChevronDown, + IconSettings, +} from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions"; @@ -30,7 +42,6 @@ import { IntegrationAvatar } from "@homarr/ui"; import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form"; import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid"; import { InitStepCard } from "../../_components/init-step-card"; -import { DockerDiscoveryIndicator } from "./docker-discovery-panel"; type Phase = "select" | "configure" | "done"; @@ -65,8 +76,15 @@ export const InitIntegrations = () => { const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : []; const dockerApps = dockerData?.status === "success" ? dockerData.apps : []; + const totalDetected = dockerIntegrations.length + dockerApps.length; + + const detectedKinds = useMemo( + () => new Set(dockerIntegrations.map((i) => i.kind)), + [dockerIntegrations], + ); const [selectedAppIds, setSelectedAppIds] = useState>(new Set()); + const [appsExpanded, setAppsExpanded] = useState(false); const dockerApplied = useRef(false); useEffect(() => { @@ -92,6 +110,9 @@ export const InitIntegrations = () => { setDrafts(newDrafts); setSelectedKinds(Array.from(newKinds)); setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId))); + if (dockerData.apps.length > 0) { + setAppsExpanded(true); + } }, [dockerData]); const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => { @@ -203,27 +224,24 @@ export const InitIntegrations = () => { ); } + const selectedAppCount = dockerApps.filter((app) => selectedAppIds.has(app.containerId)).length; + return ( {t("description")} - - - } title={t("urlTemplate.label")}> + } title={t("urlTemplate.label")}> - {t("urlTemplate.hint")} + + {t("urlTemplate.warning")} + setBaseHost(e.currentTarget.value)} + data-autofocus /> { + {isDockerLoading && ( + + + + + + + {t("docker.scanning")} + + + + + )} + + {!isDockerLoading && totalDetected > 0 && ( + + + + + + + {t("docker.summary", { + apps: String(totalDetected), + integrations: String(dockerIntegrations.length), + })} + + + + )} + + {dockerApps.length > 0 && ( + + + setAppsExpanded((prev) => !prev)} + > + + {t("docker.appSectionLabel", { count: String(selectedAppCount) })} + + + + + + {dockerApps.map((app) => { + const isSelected = selectedAppIds.has(app.containerId); + return ( + handleToggleApp(app.containerId)} + > + + + + {app.containerName} + + {isSelected && } + + + {app.containerName.at(0)?.toUpperCase()} + + + + ); + })} + + + + + )} + - + ); }; -interface SupportedIntegrationsProps { - integrations: IntegrationKind[]; - mt: string; -} - -const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) => { +const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => { if (integrations.length === 0) { return null; } @@ -165,7 +180,7 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) const moreCount = integrations.length - countToShow; return ( -
+ {integrations.slice(0, countToShow).map((integration) => ( @@ -191,6 +206,6 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) )} -
+ ); }; 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 0c5313ad7..cbd347ba1 100644 --- a/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-multi-select-grid.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; -import { IconCheck, IconPuzzle } from "@tabler/icons-react"; +import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react"; import type { IntegrationKind } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; @@ -15,6 +15,7 @@ interface IntegrationMultiSelectGridProps { onSelectionChange: (kinds: IntegrationKind[]) => void; enableMockIntegration?: boolean; onboarding?: boolean; + detectedKinds?: Set; } export const IntegrationMultiSelectGrid = ({ @@ -22,6 +23,7 @@ export const IntegrationMultiSelectGrid = ({ onSelectionChange, enableMockIntegration = false, onboarding = false, + detectedKinds, }: IntegrationMultiSelectGridProps) => { const [search, setSearch] = useState(""); const selectedKinds = useMemo(() => new Set(selectedKindsArray), [selectedKindsArray]); @@ -44,7 +46,15 @@ export const IntegrationMultiSelectGrid = ({ () => buildSortedIntegrations({ enableMockIntegration, onboarding }), [enableMockIntegration, onboarding], ); - const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]); + + const sorted = useMemo(() => { + if (!detectedKinds || detectedKinds.size === 0) return integrations; + const detected = integrations.filter((i) => detectedKinds.has(i.kind)); + const rest = integrations.filter((i) => !detectedKinds.has(i.kind)); + return [...detected, ...rest]; + }, [integrations, detectedKinds]); + + const filtered = useMemo(() => filterIntegrations(sorted, search), [sorted, search]); return ( {filtered.map((integration) => { const isSelected = selectedKinds.has(integration.kind); + const isDetected = detectedKinds?.has(integration.kind) ?? false; return ( - - + + + {integration.name} - {isSelected && } + {isSelected && } -
- -
- - - + + {isDetected && ( + }> + {t("integration.grid.detected")} + + )} + {integration.categories.slice(0, 2).map((cat) => ( {categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat} diff --git a/apps/nextjs/src/components/integration/integration-select-grid.tsx b/apps/nextjs/src/components/integration/integration-select-grid.tsx index a9aaefb67..f4905dd2a 100644 --- a/apps/nextjs/src/components/integration/integration-select-grid.tsx +++ b/apps/nextjs/src/components/integration/integration-select-grid.tsx @@ -37,16 +37,15 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false withBorder > - - {integration.name} - + + + + {integration.name} + + -
- -
- - - + + {integration.categories.slice(0, 2).map((cat) => ( {categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat} diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx index d18f26515..6ac726ac0 100644 --- a/packages/modals-collection/src/apps/app-select-modal.tsx +++ b/packages/modals-collection/src/apps/app-select-modal.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Button, Card, Center, Stack, Text } from "@mantine/core"; +import { Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; import { IconPlus } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; @@ -55,44 +55,83 @@ export const AppSelectModal = createModal(({ actions, inner }} > {innerProps.withCreate && ( - - - -
- -
- + e.currentTarget.style.setProperty("--_hover-opacity", "1")} + onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + > + + + + {t("app.action.create.title")} - - {t("app.action.create.description")} - - - -
+
)} {filteredApps.map((app) => ( - - - -
- {app.name} -
- + e.currentTarget.style.setProperty("--_hover-opacity", "1")} + onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + > + + + {app.name} + {app.name} - + + + {app.description ?? ""} - - -
+
))} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index e63105773..d371d0d2d 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -115,6 +115,7 @@ "label": "Base URL", "placeholder": "homelab.local or 192.168.1.1", "hint": "Enter your base domain or IP to auto-fill integration URLs", + "warning": "Setting your base URL now will auto-fill all integration URLs, saving you time during setup", "preview": "e.g. {example}", "mode": { "subdomain": "Subdomain", @@ -124,6 +125,7 @@ "docker": { "detected": "Docker detected {count} services", "detectedBreakdown": "{integrations} integrations ยท {apps} apps", + "summary": "Homarr detected {apps} apps, {integrations} of which have integrations built-in", "appBadge": "App", "appSectionLabel": "Apps to add to dashboard ({count} selected)", "scanning": "Scanning Docker...", @@ -780,7 +782,8 @@ }, "grid": { "widgetCount": "{count} widgets", - "noWidgets": "No widgets" + "noWidgets": "No widgets", + "detected": "Detected via Docker" }, "category": { "dnsHole": "DNS", diff --git a/packages/ui/src/select-grid-layout.tsx b/packages/ui/src/select-grid-layout.tsx index fd7f076fc..10f48f3df 100644 --- a/packages/ui/src/select-grid-layout.tsx +++ b/packages/ui/src/select-grid-layout.tsx @@ -5,7 +5,7 @@ import { Input, ScrollArea, SimpleGrid, Stack } from "@mantine/core"; import { IconSearch } from "@tabler/icons-react"; export const selectGridCols = { base: 2, xs: 3, sm: 4, md: 5 }; -export const selectGridCardHeight = 180; +export const selectGridCardHeight = 140; export const selectGridScrollMaxHeight = "70vh"; interface SelectGridLayoutProps { @@ -14,6 +14,7 @@ interface SelectGridLayoutProps { placeholder: string; onSearchKeyDown?: (event: KeyboardEvent) => void; disableScroll?: boolean; + disableAutoFocus?: boolean; children: ReactNode; } @@ -23,6 +24,7 @@ export const SelectGridLayout = ({ placeholder, onSearchKeyDown, disableScroll = false, + disableAutoFocus = false, children, }: SelectGridLayoutProps) => { const grid = ( @@ -38,7 +40,7 @@ export const SelectGridLayout = ({ onChange={(event) => onSearchChange(event.currentTarget.value)} leftSection={} placeholder={placeholder} - data-autofocus + data-autofocus={disableAutoFocus ? undefined : true} onKeyDown={onSearchKeyDown} />