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,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<Set<string>>(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 (
<InitStepCard>
<Stack>
<Text>{t("description")}</Text>
<DockerDiscoveryIndicator
integrations={dockerIntegrations}
apps={dockerApps}
selectedAppIds={selectedAppIds}
onToggleApp={handleToggleApp}
isLoading={isDockerLoading}
/>
<Alert variant="light" color="blue" icon={<IconInfoCircle />} title={t("urlTemplate.label")}>
<Alert variant="outline" color="orange" icon={<IconAlertTriangle />} title={t("urlTemplate.label")}>
<Stack gap="xs">
<Text size="sm">{t("urlTemplate.hint")}</Text>
<Text size="sm" fw={500}>
{t("urlTemplate.warning")}
</Text>
<Group gap="sm" grow>
<TextInput
placeholder={t("urlTemplate.placeholder")}
value={baseHost}
onChange={(e) => setBaseHost(e.currentTarget.value)}
data-autofocus
/>
<SegmentedControl
value={urlMode}
@@ -243,12 +261,102 @@ export const InitIntegrations = () => {
</Stack>
</Alert>
{isDockerLoading && (
<Paper withBorder p="sm" radius="md">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Text size="sm" fw={500}>
{t("docker.scanning")}
</Text>
<Loader size="sm" color="blue" type="dots" />
</Group>
</Paper>
)}
{!isDockerLoading && totalDetected > 0 && (
<Paper withBorder p="sm" radius="md" style={{ borderColor: "var(--mantine-color-teal-4)" }}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="teal" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Text size="sm">
{t("docker.summary", {
apps: String(totalDetected),
integrations: String(dockerIntegrations.length),
})}
</Text>
</Group>
</Paper>
)}
<IntegrationMultiSelectGrid
selectedKinds={selectedKinds}
onSelectionChange={handleSelectionChange}
detectedKinds={detectedKinds}
onboarding
/>
{dockerApps.length > 0 && (
<Paper withBorder p="sm" radius="md">
<Stack gap="xs">
<Group
justify="space-between"
wrap="nowrap"
style={{ cursor: "pointer" }}
onClick={() => setAppsExpanded((prev) => !prev)}
>
<Text size="sm" fw={500}>
{t("docker.appSectionLabel", { count: String(selectedAppCount) })}
</Text>
<IconChevronDown
size={16}
color="var(--mantine-color-dimmed)"
style={{
transform: appsExpanded ? "rotate(180deg)" : undefined,
transition: "transform 200ms ease",
}}
/>
</Group>
<Collapse expanded={appsExpanded}>
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
{dockerApps.map((app) => {
const isSelected = selectedAppIds.has(app.containerId);
return (
<Card
key={app.containerId}
h={80}
p="xs"
withBorder
style={{
cursor: "pointer",
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: isSelected ? 2 : undefined,
opacity: isSelected ? 1 : 0.6,
}}
onClick={() => handleToggleApp(app.containerId)}
>
<Stack justify="space-between" h="100%" gap={4} align="center">
<Group justify="space-between" w="100%" wrap="nowrap">
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
</Group>
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
{app.containerName.at(0)?.toUpperCase()}
</Avatar>
</Stack>
</Card>
);
})}
</SimpleGrid>
</Collapse>
</Stack>
</Paper>
)}
<Group justify="space-between">
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
{t("action.skip")}
@@ -0,0 +1,29 @@
"use client";
import { Group, Slider, Text } from "@mantine/core";
import { IconTextSize } from "@tabler/icons-react";
import { useLogContext } from "./log-context";
export const LogFontSizeSlider = () => {
const { fontSize, setFontSize } = useLogContext();
return (
<Group gap="xs" wrap="nowrap" w={160}>
<IconTextSize size={18} style={{ flexShrink: 0 }} />
<Slider
min={10}
max={24}
step={1}
value={fontSize}
onChange={setFontSize}
label={(val) => `${val}px`}
style={{ flex: 1 }}
size="xs"
/>
<Text size="xs" c="dimmed" w={32} ta="right">
{fontSize}px
</Text>
</Group>
);
};
@@ -6,10 +6,23 @@ import { createContext, useContext, useMemo, useState } from "react";
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
import { logLevels } from "@homarr/core/infrastructure/logs/constants";
const FONT_SIZE_KEY = "homarr-log-font-size";
const DEFAULT_FONT_SIZE = 14;
const readStoredFontSize = () => {
if (typeof window === "undefined") return DEFAULT_FONT_SIZE;
const stored = localStorage.getItem(FONT_SIZE_KEY);
if (!stored) return DEFAULT_FONT_SIZE;
const parsed = Number(stored);
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 10), 24) : DEFAULT_FONT_SIZE;
};
const LogContext = createContext<{
level: LogLevel;
setLevel: (level: LogLevel) => void;
activeLevels: LogLevel[];
fontSize: number;
setFontSize: (size: number) => void;
} | null>(null);
interface LogContextProviderProps extends PropsWithChildren {
@@ -18,9 +31,19 @@ interface LogContextProviderProps extends PropsWithChildren {
export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => {
const [level, setLevel] = useState(defaultLevel);
const [fontSize, setFontSizeRaw] = useState(readStoredFontSize);
const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]);
return <LogContext.Provider value={{ level, setLevel, activeLevels }}>{children}</LogContext.Provider>;
const setFontSize = (size: number) => {
setFontSizeRaw(size);
localStorage.setItem(FONT_SIZE_KEY, String(size));
};
return (
<LogContext.Provider value={{ level, setLevel, activeLevels, fontSize, setFontSize }}>
{children}
</LogContext.Provider>
);
};
export const useLogContext = () => {
@@ -13,6 +13,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { createMetaTitle } from "~/metadata";
import { ClientSideTerminalComponent } from "./client";
import { LogFontSizeSlider } from "./font-size-slider";
import { LogLevelSelection } from "./level-selection";
import { LogContextProvider } from "./log-context";
@@ -38,7 +39,10 @@ export default async function LogsManagementPage() {
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
<Group justify="space-between" align="center" wrap="nowrap">
<DynamicBreadcrumb />
<LogLevelSelection />
<Group gap="md" wrap="nowrap">
<LogFontSizeSlider />
<LogLevelSelection />
</Group>
</Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideTerminalComponent />
@@ -13,9 +13,11 @@ import classes from "./terminal.module.css";
export const TerminalComponent = () => {
const ref = useRef<HTMLDivElement>(null);
const { activeLevels } = useLogContext();
const { activeLevels, fontSize } = useLogContext();
const terminalRef = useRef<Terminal>(null);
const fitAddonRef = useRef<FitAddon>(null);
clientApi.log.subscribe.useSubscription(
{
levels: activeLevels,
@@ -26,7 +28,6 @@ export const TerminalComponent = () => {
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
},
onError(err) {
// This makes sense as logging might cause an infinite loop
alert(err);
},
},
@@ -43,14 +44,14 @@ export const TerminalComponent = () => {
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
// This is a hack to make sure the terminal is rendered before we try to fit it
// You can blame @Meierschlumpf for this
setTimeout(() => {
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
@@ -60,5 +61,12 @@ export const TerminalComponent = () => {
canvasAddon.dispose();
};
}, []);
useEffect(() => {
if (!terminalRef.current) return;
terminalRef.current.options.fontSize = fontSize;
fitAddonRef.current?.fit();
}, [fontSize]);
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
};
@@ -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}
@@ -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<AppSelectModalProps>(({ actions, inner
}}
>
{innerProps.withCreate && (
<Card h={selectGridCardHeight} withBorder>
<Stack justify="space-between" h="100%" gap="xs">
<Stack gap="xs">
<Center>
<IconPlus size={24} />
</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">
<IconPlus size={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{t("app.action.create.title")}
</Text>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed" lineClamp={2}>
{t("app.action.create.description")}
</Text>
</Stack>
<Button onClick={handleAddNewApp} variant="light" size="xs" mt="auto" fullWidth>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{t("app.action.create.description")}
</Text>
</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={handleAddNewApp} variant="light" size="xs" fullWidth>
{t("app.action.create.action")}
</Button>
</Stack>
</Box>
</Card>
)}
{filteredApps.map((app) => (
<Card key={app.id} h={selectGridCardHeight} withBorder>
<Stack justify="space-between" h="100%" gap="xs">
<Stack gap="xs">
<Center>
<img src={app.iconUrl} alt={app.name} width={24} height={24} />
</Center>
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center" lineClamp={2}>
<Card
key={app.id}
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">
<img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{app.name}
</Text>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed" lineClamp={2}>
</Group>
<Tooltip label={app.description} multiline w={250} disabled={!app.description}>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{app.description ?? ""}
</Text>
</Stack>
<Button onClick={() => handleSelect(app)} variant="light" size="xs" mt="auto" fullWidth>
</Tooltip>
</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={() => handleSelect(app)} variant="light" size="xs" fullWidth>
{t("app.action.select.action", { app: app.name })}
</Button>
</Stack>
</Box>
</Card>
))}
+4 -1
View File
@@ -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",
+4 -2
View File
@@ -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<HTMLInputElement>) => 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={<IconSearch />}
placeholder={placeholder}
data-autofocus
data-autofocus={disableAutoFocus ? undefined : true}
onKeyDown={onSearchKeyDown}
/>