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
This commit is contained in:
Thomas Camlong committed 2026-06-04 13:27:26 +02:00
1 parent e98487d2aa
commit 3cb2932474
11 files changed
+331 -87

No files matched your search

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Avatar, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { Avatar, Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createId, objectEntries } from "@homarr/common";
@@ -129,34 +129,49 @@ const WidgetItem = ({
const t = useI18n();
return (
<Card h={selectGridCardHeight} withBorder>
<Stack justify="space-between" h="100%" gap="xs">
<Stack gap="xs">
<Center>
<item.icon />
</Center>
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center" lineClamp={2}>
<Card
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<item.icon size={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{item.name}
</Text>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed" lineClamp={2}>
</Group>
<Tooltip label={item.description} multiline w={250} disabled={!item.description}>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
</Stack>
<SupportedIntegrations mt="auto" integrations={item.supportedIntegrations} />
</Tooltip>
<SupportedIntegrations integrations={item.supportedIntegrations} />
</Stack>
<Box
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
{t(`item.create.addToBoard`)}
</Button>
</Stack>
</Box>
</Card>
);
};
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 (
<Center mt={mt}>
<Group gap={2} mt="auto">
<Tooltip.Group closeDelay={100}>
<Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => (
@@ -191,6 +206,6 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps)
)}
</Group>
</Tooltip.Group>
</Center>
</Group>
);
};
@@ -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<IntegrationKind>;
}
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 (
<SelectGridLayout
@@ -52,9 +62,11 @@ export const IntegrationMultiSelectGrid = ({
onSearchChange={setSearch}
placeholder={`${t("integration.page.list.search")}...`}
disableScroll={onboarding}
disableAutoFocus={onboarding}
>
{filtered.map((integration) => {
const isSelected = selectedKinds.has(integration.kind);
const isDetected = detectedKinds?.has(integration.kind) ?? false;
return (
<Card
key={integration.kind}
@@ -68,19 +80,21 @@ export const IntegrationMultiSelectGrid = ({
withBorder
>
<Stack justify="space-between" h="100%" gap="xs">
<Group justify="space-between" wrap="nowrap">
<Text fw={500} lh={1.2} size="sm" lineClamp={1} style={{ flex: 1 }}>
<Group gap="sm" wrap="nowrap" align="flex-start">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Text fw={500} lh={1.2} size="sm" lineClamp={2} style={{ flex: 1 }}>
{integration.name}
</Text>
{isSelected && <IconCheck size={18} color="var(--mantine-color-blue-6)" />}
{isSelected && <IconCheck size={18} color="var(--mantine-color-blue-6)" style={{ flexShrink: 0 }} />}
</Group>
<Center>
<IntegrationAvatar kind={integration.kind} size="lg" />
</Center>
<Stack gap={4} align="center">
<Group gap={4} justify="center" wrap="wrap">
<Stack gap={4} mt="auto">
{isDetected && (
<Badge variant="light" color="teal" size="xs" leftSection={<IconBrandDocker size={12} />}>
{t("integration.grid.detected")}
</Badge>
)}
<Group gap={4} wrap="wrap">
{integration.categories.slice(0, 2).map((cat) => (
<Badge key={cat} variant="light" size="xs">
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}
@@ -37,16 +37,15 @@ export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false
withBorder
>
<Stack justify="space-between" h="100%" gap="xs">
<Text fw={500} ta="center" lh={1.2} size="sm" lineClamp={1}>
{integration.name}
</Text>
<Group gap="sm" wrap="nowrap" align="flex-start">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Text fw={500} lh={1.2} size="sm" lineClamp={2}>
{integration.name}
</Text>
</Group>
<Center>
<IntegrationAvatar kind={integration.kind} size="lg" />
</Center>
<Stack gap={4} align="center">
<Group gap={4} justify="center" wrap="wrap">
<Stack gap={4} mt="auto">
<Group gap={4} wrap="wrap">
{integration.categories.slice(0, 2).map((cat) => (
<Badge key={cat} variant="light" size="xs">
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}