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:
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}
|
||||
|
||||
Reference in new issue
Block a user