Compare commits

...
88 changed files with 940 additions and 620 deletions

No files matched your search

@@ -19,35 +19,11 @@
align-self: start; align-self: start;
box-sizing: border-box; box-sizing: border-box;
height: var(--board-gutter-viewport-height); height: var(--board-gutter-viewport-height);
padding-top: 24px;
margin-top: -24px;
overflow: clip; overflow: clip;
} }
.gutter[data-board-editing="true"] { .gutter[data-board-editing="true"] {
outline: 1px dashed var(--mantine-color-default-border); outline: 0.0625rem solid var(--mantine-color-default-border);
outline-offset: -1px; outline-offset: -0.0625rem;
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
} }
.gutter[data-board-editing="true"]::before {
position: sticky;
top: 0;
inset-inline-start: 0;
z-index: 2;
display: block;
width: fit-content;
height: 24px;
padding-inline: var(--mantine-spacing-xs);
margin-top: -24px;
overflow: hidden;
font-size: var(--mantine-font-size-xs);
line-height: 24px;
color: var(--mantine-color-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
content: attr(aria-label);
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
border-radius: var(--mantine-radius-sm);
}
@@ -1,5 +1,6 @@
"use client"; "use client";
import type { PropsWithChildren } from "react";
import { Box, Paper, Stack, Text } from "@mantine/core"; import { Box, Paper, Stack, Text } from "@mantine/core";
import { IconBulb } from "@tabler/icons-react"; import { IconBulb } from "@tabler/icons-react";
@@ -22,6 +23,7 @@ import {
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas"; import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary"; import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host"; import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse"; import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background"; import { BoardBackgroundVideo } from "~/components/layout/background";
@@ -31,6 +33,12 @@ import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32; const APP_SHELL_INLINE_PADDING = 32;
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
<GridEditorRegistryProvider>
<BoardSelectionProvider>{children}</BoardSelectionProvider>
</GridEditorRegistryProvider>
);
export const ClientBoard = () => { export const ClientBoard = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const t = useI18n("board.landmark"); const t = useI18n("board.landmark");
@@ -68,7 +76,7 @@ export const ClientBoard = () => {
.join(" "); .join(" ");
const content = ( const content = (
<BoardSelectionProvider> <BoardSelectionGridProvider>
<BoardAdvancedFocusProvider> <BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board> <Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo /> <BoardBackgroundVideo />
@@ -144,7 +152,7 @@ export const ClientBoard = () => {
</FloatingTip> </FloatingTip>
</Box> </Box>
</BoardAdvancedFocusProvider> </BoardAdvancedFocusProvider>
</BoardSelectionProvider> </BoardSelectionGridProvider>
); );
if (representativeWidth === null) return content; if (representativeWidth === null) return content;
@@ -130,6 +130,45 @@
color: var(--mantine-color-text); color: var(--mantine-color-text);
} }
.widgetPanel[data-board-widget] {
background: transparent;
}
.widgetPanel[data-board-widget] .panelHeader,
.widgetPanel[data-board-widget] .composerWrap {
border-color: rgb(from var(--mantine-color-default-border) r g b / var(--opacity, 1));
}
.widgetPanel[data-board-widget] .composerWrap,
.widgetPanel[data-board-widget] .composer,
.widgetPanel[data-board-widget] .composerQuote,
.widgetPanel[data-board-widget] .pendingQuestionDock {
background: transparent;
}
.widgetPanel[data-board-widget] .suggestion,
.widgetPanel[data-board-widget] .runtimeSelectorTrigger,
.widgetPanel[data-board-widget] .composerContext,
.widgetPanel[data-board-widget] .providerQuotaTrigger {
border-color: rgb(from var(--mantine-color-default-border) r g b / var(--opacity, 1));
background: transparent;
}
.widgetPanel[data-board-widget] .suggestion:hover:not(:disabled),
.widgetPanel[data-board-widget] .runtimeSelectorTrigger:hover:not(:disabled),
.widgetPanel[data-board-widget] .composerContext:hover:not(:disabled),
.widgetPanel[data-board-widget] .providerQuotaTrigger:hover:not(:disabled) {
background: rgb(from var(--mantine-color-default-hover) r g b / var(--opacity, 1));
}
.widgetPanel[data-board-widget] .suggestion:focus-visible,
.widgetPanel[data-board-widget] .runtimeSelectorTrigger:focus-visible,
.widgetPanel[data-board-widget] .composerContext:focus-visible,
.widgetPanel[data-board-widget] .providerQuotaTrigger:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.widgetState { .widgetState {
container-type: inline-size; container-type: inline-size;
overflow: auto; overflow: auto;
@@ -157,30 +196,12 @@
border-bottom: 1px solid var(--mantine-color-default-border); border-bottom: 1px solid var(--mantine-color-default-border);
} }
.panelIdentity {
min-width: 0;
flex: 1 1 auto;
}
.panelIdentityIcon {
flex: 0 0 auto;
}
.panelIdentityTitle,
.panelThreadTitle {
max-width: 14rem;
}
.panelActions { .panelActions {
min-width: 0; min-width: 0;
flex: 0 0 auto; flex: 0 0 auto;
margin-inline-start: auto; margin-inline-start: auto;
} }
.newConversationButton {
flex: 0 0 auto;
}
.panelAction { .panelAction {
width: 2.625rem; width: 2.625rem;
height: 2.625rem; height: 2.625rem;
@@ -207,21 +228,6 @@
display: none; display: none;
} }
.newConversationButton {
width: 2.625rem;
max-width: 2.625rem;
height: 2.625rem;
padding-inline: 0.75rem;
}
.newConversationButton :global(.mantine-Button-section) {
margin-inline: 0;
}
.newConversationButton :global(.mantine-Button-label) {
display: none;
}
.messages { .messages {
padding: 0.5rem 0.65rem 1rem; padding: 0.5rem 0.65rem 1rem;
} }
@@ -277,8 +283,7 @@
@container assistant-widget (max-width: 22rem) { @container assistant-widget (max-width: 22rem) {
.panelAction, .panelAction,
.historyButton, .historyButton {
.newConversationButton {
width: 2.25rem; width: 2.25rem;
max-width: 2.25rem; max-width: 2.25rem;
height: 2.25rem; height: 2.25rem;
@@ -301,10 +306,6 @@
.composerContextLabel { .composerContextLabel {
display: none; display: none;
} }
.panelThreadTitle {
display: none;
}
} }
@container assistant-widget (max-width: 18rem) { @container assistant-widget (max-width: 18rem) {
@@ -2368,7 +2368,7 @@ const EmptyThread = () => {
<Box className={classes.empty}> <Box className={classes.empty}>
<Stack align="center" gap="lg" maw={560} w="100%"> <Stack align="center" gap="lg" maw={560} w="100%">
<Stack align="center" gap="xs" maw={430}> <Stack align="center" gap="xs" maw={430}>
<ThemeIcon size={52} radius="xl" variant="light" color="red"> <ThemeIcon size={52} radius="xl" variant="light">
<IconRobot size={27} /> <IconRobot size={27} />
</ThemeIcon> </ThemeIcon>
<Text size="xl" fw={700}> <Text size="xl" fw={700}>
@@ -2957,7 +2957,6 @@ const Composer = (props: ComposerProps) => {
) : ( ) : (
<ComposerPrimitive.Send asChild> <ComposerPrimitive.Send asChild>
<ActionIcon <ActionIcon
color="red"
variant="filled" variant="filled"
size="lg" size="lg"
aria-label={ aria-label={
@@ -3162,27 +3161,6 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
onDismiss?: () => void; onDismiss?: () => void;
} }
const AssistantPanelIdentity = () => {
const t = useI18n("assistant");
const threadTitle = useAuiState((state) => state.threadListItem.title);
return (
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
<ThemeIcon className={classes.panelIdentityIcon} variant="light" color="red" radius="xl" size="md">
<IconRobot size={16} />
</ThemeIcon>
<Box miw={0}>
<Text className={classes.panelIdentityTitle} size="sm" fw={700} truncate>
{t("title")}
</Text>
<Text className={classes.panelThreadTitle} size="xs" c="dimmed" truncate>
{threadTitle ?? t("newConversation")}
</Text>
</Box>
</Group>
);
};
export const AssistantConversationSurface = ({ export const AssistantConversationSurface = ({
isRunning, isRunning,
pendingAction, pendingAction,
@@ -3212,23 +3190,20 @@ export const AssistantConversationSurface = ({
return ( return (
<AssistantDirectiveEntitiesProvider> <AssistantDirectiveEntitiesProvider>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs"> <Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
<AssistantPanelIdentity />
<Group className={classes.panelActions} gap={2} wrap="nowrap"> <Group className={classes.panelActions} gap={2} wrap="nowrap">
<ConversationHistory /> <ConversationHistory />
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} /> <ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
<AutoApprovalControl /> <AutoApprovalControl />
<Tooltip label={t("newConversation")}> <Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild> <ThreadListPrimitive.New asChild>
<Button <ActionIcon
className={classes.newConversationButton} className={classes.panelAction}
variant="subtle" variant="subtle"
color="gray" color="gray"
size="compact-sm"
leftSection={<IconPlus size={17} />}
aria-label={t("newConversation")} aria-label={t("newConversation")}
> >
{t("newConversation")} <IconPlus size={17} />
</Button> </ActionIcon>
</ThreadListPrimitive.New> </ThreadListPrimitive.New>
</Tooltip> </Tooltip>
{onExpand && ( {onExpand && (
@@ -16,7 +16,7 @@ const AssistantPromptDetail = () => {
const t = useI18n("assistant"); const t = useI18n("assistant");
return ( return (
<Group px="md" py="sm" wrap="nowrap"> <Group px="md" py="sm" wrap="nowrap">
<ThemeIcon color="red" variant="light" radius="xl"> <ThemeIcon variant="light" radius="xl">
<IconRobot size={17} /> <IconRobot size={17} />
</ThemeIcon> </ThemeIcon>
<Stack gap={1}> <Stack gap={1}>
@@ -137,7 +137,7 @@ const CompactAssistantWidget = ({
<Box className={classes.widgetPanel}> <Box className={classes.widgetPanel}>
<Group className={classes.compactState} justify="space-between" wrap="nowrap" gap="sm"> <Group className={classes.compactState} justify="space-between" wrap="nowrap" gap="sm">
<Group wrap="nowrap" gap="xs" miw={0}> <Group wrap="nowrap" gap="xs" miw={0}>
<ThemeIcon variant="light" color="red" radius="xl" size="lg" flex="0 0 auto"> <ThemeIcon variant="light" radius="xl" size="lg" flex="0 0 auto">
<IconRobot size={19} /> <IconRobot size={19} />
</ThemeIcon> </ThemeIcon>
<Stack className={classes.compactCopy} gap={1}> <Stack className={classes.compactCopy} gap={1}>
@@ -167,13 +167,7 @@ interface AssistantWidgetStateProps {
children?: ReactNode; children?: ReactNode;
} }
const AssistantWidgetState = ({ const AssistantWidgetState = ({ icon: Icon, title, description, color, children }: AssistantWidgetStateProps) => (
icon: Icon,
title,
description,
color = "red",
children,
}: AssistantWidgetStateProps) => (
<Center className={classes.widgetState} h="100%" p="md"> <Center className={classes.widgetState} h="100%" p="md">
<Stack align="center" gap="sm" ta="center" maw={360}> <Stack align="center" gap="sm" ta="center" maw={360}>
<ThemeIcon variant="light" color={color} radius="xl" size="xl"> <ThemeIcon variant="light" color={color} radius="xl" size="xl">
@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types"; import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { getLayoutRowCount } from "../layout"; import { getLayoutRowCount } from "../layout";
import { resolvePinnedGridCollisions } from "../sections/grid/dnd"; import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements"; import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions"; import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions"; import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
@@ -57,26 +58,29 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
const tContainer = useI18n("section.container"); const tContainer = useI18n("section.container");
const tMoveResize = useI18n("item.moveResize"); const tMoveResize = useI18n("item.moveResize");
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps; const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
const registeredGridEditors = useRegisteredGridEditors();
const minimumSize = useMemo( const minimumSize = useMemo(
() => getEntryMinimumSize(board, currentLayoutId, entry), () => getEntryMinimumSize(board, currentLayoutId, entry),
[board, currentLayoutId, entry], [board, currentLayoutId, entry],
); );
const targets = useMemo( const targets = getMoveTargets(
() => board,
getMoveTargets(board, currentLayoutId, entry, { currentLayoutId,
canvas: tLandmark("canvas"), entry,
container: tContainer("action.create"), {
leftRail: tLandmark("leftRail"), canvas: tLandmark("canvas"),
rightRail: tLandmark("rightRail"), container: tContainer("action.create"),
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }), leftRail: tLandmark("leftRail"),
located: (name, location, index) => rightRail: tLandmark("rightRail"),
tMoveResize("target.located", { numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
name, located: (name, location, index) =>
location, tMoveResize("target.located", {
index: String(index), name,
}), location,
}), index: String(index),
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize], }),
},
registeredGridEditors,
); );
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]); const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
const initialTarget = targetById.get(sourceSectionId) ?? targets[0]; const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
@@ -252,6 +256,7 @@ export const getMoveTargets = (
layoutId: string, layoutId: string,
entry: MovableEntry, entry: MovableEntry,
labels: MoveTargetLabels, labels: MoveTargetLabels,
registeredGridEditors?: ReadonlyMap<string, { placementMaxRowCount: number | null }>,
): MoveTarget[] => { ): MoveTarget[] => {
const minimum = getEntryMinimumSize(board, layoutId, entry); const minimum = getEntryMinimumSize(board, layoutId, entry);
@@ -260,13 +265,15 @@ export const getMoveTargets = (
const columnCount = getSectionColumnCount(board, layoutId, section.id); const columnCount = getSectionColumnCount(board, layoutId, section.id);
const lane = getRootSectionLane(section.xOffset); const lane = getRootSectionLane(section.xOffset);
if (columnCount === 0) return []; if (columnCount === 0) return [];
const registeredEditor = registeredGridEditors?.get(section.id);
if (lane !== "main" && registeredGridEditors && !registeredEditor) return [];
return minimum.width <= columnCount return minimum.width <= columnCount
? [ ? [
{ {
id: section.id, id: section.id,
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas, name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
columnCount, columnCount,
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id), maxRowCount: registeredEditor?.placementMaxRowCount ?? getSectionMaxRowCount(board, layoutId, section.id),
}, },
] ]
: []; : [];
@@ -3,8 +3,8 @@
The board uses fixed logical units: The board uses fixed logical units:
- one `1 × 1` item is exactly `200 × 200` logical pixels; - one `1 × 1` item is exactly `200 × 200` logical pixels;
- the gap is always `24` logical pixels, giving a `224` pixel track pitch; - the gap matches Mantine's `sm` spacing at `12` logical pixels, giving a `212` pixel track pitch;
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`); - multi-cell items include the gaps between their cells (`2 × 1` is `412 × 200`);
- the canvas is zoomed once with one uniform scale derived from its available width. - the canvas is zoomed once with one uniform scale derived from its available width.
Items keep the same logical dimensions at every viewport. The viewport reserves Items keep the same logical dimensions at every viewport. The viewport reserves
@@ -3,7 +3,7 @@
* individual cards never change their internal layout at different viewports. * individual cards never change their internal layout at different viewports.
*/ */
export const LOGICAL_GRID_CELL_SIZE = 200; export const LOGICAL_GRID_CELL_SIZE = 200;
export const LOGICAL_GRID_GAP = 24; export const LOGICAL_GRID_GAP = 12;
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP; export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
export const COLLAPSED_SECTION_ROW_COUNT = 0.5; export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
@@ -23,6 +23,8 @@
* zoom and enlarges everything together. * zoom and enlarges everything together.
*/ */
--mantine-scale: var(--board-canvas-ui-scale, 1); --mantine-scale: var(--board-canvas-ui-scale, 1);
/* Keep a subtle boundary inset without widening the visual gap too much. */
--board-grid-item-inset: calc(var(--mantine-spacing-xs) / 2);
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1)); --mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1)); --mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1)); --mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
@@ -5,7 +5,12 @@ import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/bo
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock"; import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock"; import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock"; import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index"; import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalTrackSize,
getRootSectionForLane,
} from "../index";
describe("board lanes", () => { describe("board lanes", () => {
test("gutters consume columns from the main canvas", () => { test("gutters consume columns from the main canvas", () => {
@@ -126,6 +131,6 @@ describe("board lanes", () => {
}, },
]; ];
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(872); expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalTrackSize(4));
}); });
}); });
@@ -9,18 +9,18 @@ import {
} from "../index"; } from "../index";
describe("fixed dashboard geometry", () => { describe("fixed dashboard geometry", () => {
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => { test("keeps a 1x1 item exactly 200px with a fixed 12px gap", () => {
expect(getLogicalTrackSize(1)).toBe(200); expect(getLogicalTrackSize(1)).toBe(200);
expect(getLogicalTrackSize(0.5)).toBe(88); expect(getLogicalTrackSize(0.5)).toBe(94);
expect(getLogicalTrackSize(2)).toBe(424); expect(getLogicalTrackSize(2)).toBe(412);
expect(getLogicalTrackSize(3)).toBe(648); expect(getLogicalTrackSize(3)).toBe(624);
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({ expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
position: "absolute", position: "absolute",
left: 224, left: 212,
top: 448, top: 424,
width: 424, width: 412,
height: 648, height: 624,
}); });
}); });
@@ -35,7 +35,7 @@ describe("fixed dashboard geometry", () => {
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6); expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200); expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11); expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(12);
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200); expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
}); });
}); });
@@ -46,6 +46,7 @@ export const BoardContainerSection = ({ section }: Props) => {
<Card <Card
className={combineClasses( className={combineClasses(
classes.itemCard, classes.itemCard,
classes.containerCard,
options.customCssClasses.join(" "), options.customCssClasses.join(" "),
isVisuallyCollapsed && classes.collapsedContainerCard, isVisuallyCollapsed && classes.collapsedContainerCard,
)} )}
@@ -56,7 +57,7 @@ export const BoardContainerSection = ({ section }: Props) => {
root: { root: {
overflow: "visible", overflow: "visible",
"--opacity": board.opacity / 100, "--opacity": board.opacity / 100,
"--border-color": options.borderColor || undefined, "--container-border-color": options.borderColor || undefined,
}, },
}} }}
radius={board.itemRadius} radius={board.itemRadius}
@@ -66,7 +67,7 @@ export const BoardContainerSection = ({ section }: Props) => {
<Button <Button
className={classes.containerToggle} className={classes.containerToggle}
pos="absolute" pos="absolute"
top={isVisuallyCollapsed ? 0 : -24} top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"}
left={0} left={0}
w="100%" w="100%"
h={isVisuallyCollapsed ? "100%" : 24} h={isVisuallyCollapsed ? "100%" : 24}
@@ -103,8 +104,9 @@ export const BoardContainerSection = ({ section }: Props) => {
)} )}
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && ( {!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
<Badge <Badge
className={classes.containerLabel}
pos="absolute" pos="absolute"
top={-24} top="calc(var(--mantine-spacing-xs) * -1)"
left={labelLeft} left={labelLeft}
maw={`calc(100% - ${labelLeft + labelRight}px)`} maw={`calc(100% - ${labelLeft + labelRight}px)`}
size="md" size="md"
@@ -113,12 +115,7 @@ export const BoardContainerSection = ({ section }: Props) => {
c="var(--mantine-color-text)" c="var(--mantine-color-text)"
style={{ style={{
zIndex: 9, zIndex: 9,
overflow: "hidden",
pointerEvents: "none", pointerEvents: "none",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
backgroundColor: "var(--background-color)",
borderColor: "var(--border-color)",
}} }}
title={options.title} title={options.title}
data-board-container-label data-board-container-label
@@ -128,8 +125,9 @@ export const BoardContainerSection = ({ section }: Props) => {
)} )}
{options.showOpenAll && !isEditMode && ( {options.showOpenAll && !isEditMode && (
<ActionIcon <ActionIcon
className={classes.containerAction}
pos="absolute" pos="absolute"
top={isVisuallyCollapsed ? "50%" : -24} top={isVisuallyCollapsed ? "50%" : "calc(var(--mantine-spacing-xs) * -1)"}
right={8} right={8}
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }} style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
variant={options.collapsible ? "subtle" : "default"} variant={options.collapsible ? "subtle" : "default"}
@@ -7,7 +7,7 @@ import { createPortal } from "react-dom";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { getLoadedGridEditorModule, loadGridEditorAsync } from "./grid-editor-loader"; import { getLoadedGridEditorModule, loadGridEditorAsync } from "./grid-editor-loader";
import { GridEditorRegistryProvider, useRegisteredGridEditors } from "./grid-editor-registry"; import { useRegisteredGridEditors } from "./grid-editor-registry";
import { GridEditorRuntimeProvider } from "./grid-editor-runtime"; import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>; type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
@@ -54,14 +54,14 @@ export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
const GridEditor = resolvedEditorModule?.default ?? null; const GridEditor = resolvedEditorModule?.default ?? null;
return ( return (
<GridEditorRegistryProvider> <>
<GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider> <GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider>
{isEditMode && Provider && GridEditor && ( {isEditMode && Provider && GridEditor && (
<Provider> <Provider>
<RegisteredGridEditors GridEditor={GridEditor} /> <RegisteredGridEditors GridEditor={GridEditor} />
</Provider> </Provider>
)} )}
</GridEditorRegistryProvider> </>
); );
}; };
@@ -23,7 +23,12 @@ describe("dnd grid coordinates", () => {
test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => { test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => {
const scale = 0.75; const scale = 0.75;
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale }; const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
const initialShape = { left: target.left, top: target.top, width: 424 * scale, height: 200 * scale }; const initialShape = {
left: target.left,
top: target.top,
width: getLogicalTrackSize(2) * scale,
height: 200 * scale,
};
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 }; const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
expect( expect(
@@ -55,6 +55,7 @@ export interface GridEditorRenderProps {
} }
export interface RegisteredGridEditor extends GridEditorRenderProps { export interface RegisteredGridEditor extends GridEditorRenderProps {
placementMaxRowCount: number | null;
host: HTMLElement; host: HTMLElement;
disabled: boolean; disabled: boolean;
} }
@@ -51,7 +51,7 @@
.board-grid-entry[data-board-item-selected="true"]::after { .board-grid-entry[data-board-item-selected="true"]::after {
position: absolute; position: absolute;
inset: 0; inset: var(--board-grid-item-inset, 0px);
z-index: 100; z-index: 100;
content: ""; content: "";
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent); background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
@@ -121,6 +121,7 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
.board-grid-placeholder { .board-grid-placeholder {
z-index: 0; z-index: 0;
clip-path: inset(var(--board-grid-item-inset, 0px));
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent); background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
border: 2px dashed var(--mantine-primary-color-filled); border: 2px dashed var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
@@ -139,6 +140,7 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
.board-grid-resize-outline { .board-grid-resize-outline {
z-index: 1002; z-index: 1002;
clip-path: inset(var(--board-grid-item-inset, 0px));
background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent); background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent);
border: 2px solid var(--mantine-primary-color-filled); border: 2px solid var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
@@ -153,17 +155,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled); box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
} }
[data-section-kind="empty"][data-rail-placement="main"] > .board-grid-editor[data-dnd-drop-target="true"] {
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
outline-offset: 4px;
box-shadow: none;
}
[data-section-kind="empty"][data-rail-placement="main"]
> .board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
outline-color: var(--mantine-color-red-filled);
}
[data-board-container-collapsed] [data-board-container-collapsed]
> [data-board-container-body] > [data-board-container-body]
> [data-section-id] > [data-section-id]
@@ -240,11 +231,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
} }
.board-grid-resize-handle[data-grid-resize-handle="n"] { .board-grid-resize-handle[data-grid-resize-handle="n"] {
top: -0.25rem; top: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
} }
.board-grid-resize-handle[data-grid-resize-handle="s"] { .board-grid-resize-handle[data-grid-resize-handle="s"] {
bottom: -0.25rem; bottom: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
} }
.board-grid-resize-handle[data-grid-resize-handle="e"], .board-grid-resize-handle[data-grid-resize-handle="e"],
@@ -258,11 +249,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
} }
.board-grid-resize-handle[data-grid-resize-handle="e"] { .board-grid-resize-handle[data-grid-resize-handle="e"] {
right: -0.25rem; right: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
} }
.board-grid-resize-handle[data-grid-resize-handle="w"] { .board-grid-resize-handle[data-grid-resize-handle="w"] {
left: -0.25rem; left: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
} }
.board-grid-resize-handle[data-grid-resize-handle="ne"], .board-grid-resize-handle[data-grid-resize-handle="ne"],
@@ -275,42 +266,30 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
} }
.board-grid-resize-handle[data-grid-resize-handle="ne"] { .board-grid-resize-handle[data-grid-resize-handle="ne"] {
top: -0.625rem; top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
right: -0.625rem; right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
cursor: nesw-resize; cursor: nesw-resize;
} }
.board-grid-resize-handle[data-grid-resize-handle="se"] { .board-grid-resize-handle[data-grid-resize-handle="se"] {
right: -0.625rem; right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
bottom: -0.625rem; bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
cursor: nwse-resize; cursor: nwse-resize;
} }
.board-grid-resize-handle[data-grid-resize-handle="sw"] { .board-grid-resize-handle[data-grid-resize-handle="sw"] {
bottom: -0.625rem; bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
left: -0.625rem; left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
cursor: nesw-resize; cursor: nesw-resize;
} }
.board-grid-resize-handle[data-grid-resize-handle="nw"] { .board-grid-resize-handle[data-grid-resize-handle="nw"] {
top: -0.625rem; top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
left: -0.625rem; left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
cursor: nwse-resize; cursor: nwse-resize;
/* Leaves a thin draggable border around the corner instead of a solid 44x44 hit area, /* Leaves a thin draggable border around the corner instead of a solid 44x44 hit area,
so it doesn't sit on top of the container menu button anchored in this same corner. */ so it doesn't sit on top of the container menu button anchored in this same corner. */
clip-path: polygon( clip-path: polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, 4px 4px, 4px 40px, 40px 40px, 40px 4px, 4px 4px);
evenodd,
0 0,
100% 0,
100% 100%,
0 100%,
0 0,
4px 4px,
4px 40px,
40px 40px,
40px 4px,
4px 4px
);
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -46,12 +46,14 @@
.contentMount { .contentMount {
position: absolute; position: absolute;
inset: 0; inset: var(--board-grid-item-inset, 0px);
width: 100%;
height: 100%;
overflow: visible; overflow: visible;
} }
.staticItem:has([data-board-container-label]) {
z-index: 2;
}
.editorEntry { .editorEntry {
overflow: visible; overflow: visible;
cursor: grab; cursor: grab;
@@ -130,6 +130,10 @@ export const SectionGrid = ({
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements)); const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount); const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null; const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
let placementMaxRowCount = maxRowCount;
if (railPlacement !== "main") {
placementMaxRowCount = minimumViewportRowCount;
}
// A scrollable container isn't forced to grow with its content - it scrolls internally instead // A scrollable container isn't forced to grow with its content - it scrolls internally instead
// of expanding to fit every widget, so its viewport height is capped independently of rowCount. // of expanding to fit every widget, so its viewport height is capped independently of rowCount.
const isScrollableContainer = section.kind === "container" && section.options.scrollable; const isScrollableContainer = section.kind === "container" && section.options.scrollable;
@@ -175,6 +179,7 @@ export const SectionGrid = ({
columnCount, columnCount,
rowCount, rowCount,
maxRowCount, maxRowCount,
placementMaxRowCount,
placements: displayPlacements, placements: displayPlacements,
transactionPlacements: placements, transactionPlacements: placements,
className: editorClassName, className: editorClassName,
@@ -190,6 +195,7 @@ export const SectionGrid = ({
entryElementStore, entryElementStore,
isInteractionDisabled, isInteractionDisabled,
maxRowCount, maxRowCount,
placementMaxRowCount,
placements, placements,
rowCount, rowCount,
section, section,
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, test } from "vitest"; import { beforeEach, describe, expect, test } from "vitest";
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry"; import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
describe("grid preview geometry", () => { describe("grid preview geometry", () => {
@@ -25,8 +27,12 @@ describe("grid preview geometry", () => {
expect(elements.get("active")?.hasAttribute("data-grid-preview")).toBe(false); expect(elements.get("active")?.hasAttribute("data-grid-preview")).toBe(false);
expect(elements.get("active")?.getAttribute("data-dnd-active")).toBe("true"); expect(elements.get("active")?.getAttribute("data-dnd-active")).toBe("true");
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe("-448px"); expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe(
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe("448px"); `${-2 * LOGICAL_GRID_PITCH}px`,
);
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe(
`${2 * LOGICAL_GRID_PITCH}px`,
);
expect(elements.get("displaced")?.dataset.gridX).toBe("0"); expect(elements.get("displaced")?.dataset.gridX).toBe("0");
expect(elements.get("displaced")?.dataset.gridY).toBe("2"); expect(elements.get("displaced")?.dataset.gridY).toBe("2");
@@ -57,10 +63,10 @@ describe("grid preview geometry", () => {
const active = elements.get("active"); const active = elements.get("active");
expect(active?.getAttribute("data-grid-preview")).toBe("true"); expect(active?.getAttribute("data-grid-preview")).toBe("true");
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe("224px"); expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe(`${LOGICAL_GRID_PITCH}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe("224px"); expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe(`${LOGICAL_GRID_PITCH}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe("648px"); expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalTrackSize(3)}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe("872px"); expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalTrackSize(4)}px`);
expect(active?.dataset.gridW).toBe("3"); expect(active?.dataset.gridW).toBe("3");
expect(active?.dataset.gridH).toBe("4"); expect(active?.dataset.gridH).toBe("4");
}); });
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { getLogicalTrackSize } from "~/components/board/layout";
import { createGridResizeOutlineController } from "../grid-resize-outline"; import { createGridResizeOutlineController } from "../grid-resize-outline";
describe("grid resize outline", () => { describe("grid resize outline", () => {
@@ -28,8 +30,8 @@ describe("grid resize outline", () => {
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]"); const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
expect(outline?.style.left).toBe("0px"); expect(outline?.style.left).toBe("0px");
expect(outline?.style.top).toBe("0px"); expect(outline?.style.top).toBe("0px");
expect(outline?.style.width).toBe("200px"); expect(outline?.style.width).toBe(`${getLogicalTrackSize(1)}px`);
expect(outline?.style.height).toBe("424px"); expect(outline?.style.height).toBe(`${getLogicalTrackSize(2)}px`);
expect(outline?.dataset.gridResizeValid).toBe("false"); expect(outline?.dataset.gridResizeValid).toBe("false");
controller.schedule(null); controller.schedule(null);
@@ -11,6 +11,18 @@
border-color: var(--border-color) !important; border-color: var(--border-color) !important;
} }
.containerCard {
@mixin dark {
--container-default-border-color: var(--mantine-color-dark-4);
}
@mixin light {
--container-default-border-color: var(--mantine-color-gray-3);
}
--border-color: rgb(
from var(--container-border-color, var(--container-default-border-color)) r g b / var(--opacity, 1)
);
}
.itemCard:global(.iframe-wrapper) { .itemCard:global(.iframe-wrapper) {
background-color: transparent !important; background-color: transparent !important;
border-color: transparent !important; border-color: transparent !important;
@@ -26,8 +38,8 @@
z-index: 9; z-index: 9;
overflow: hidden; overflow: hidden;
color: var(--mantine-color-text); color: var(--mantine-color-text);
background-color: var(--background-color); background-color: var(--background-color) !important;
border-color: var(--border-color); border-color: var(--border-color) !important;
} }
.containerToggle :global(.mantine-Button-inner) { .containerToggle :global(.mantine-Button-inner) {
@@ -42,6 +54,24 @@
white-space: nowrap; white-space: nowrap;
} }
.containerLabel,
.containerAction {
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
.containerLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.containerLabel :global(.mantine-Badge-label) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.containerBody { .containerBody {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
@@ -31,7 +31,7 @@ interface BoardSelectionContextValue {
copySelectedItems: () => Promise<void>; copySelectedItems: () => Promise<void>;
pasteItems: () => Promise<void>; pasteItems: () => Promise<void>;
removeSelectedItems: () => void; removeSelectedItems: () => void;
moveSelectedItemsToSection: (sectionId: string) => void; moveSelectedItemsToSection: (sectionId: string, maxRowCount?: number | null) => void;
} }
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null); const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
@@ -229,7 +229,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
}, [board.items, clearSelection, persistBoard, selectedItemIds, tCommon, tSelection]); }, [board.items, clearSelection, persistBoard, selectedItemIds, tCommon, tSelection]);
const moveSelectedItemsToSection = useCallback( const moveSelectedItemsToSection = useCallback(
(targetSectionId: string) => { (targetSectionId: string, maxRowCount?: number | null) => {
if (selectedItemIds.size === 0) return; if (selectedItemIds.size === 0) return;
const idsToMove = new Set(selectedItemIds); const idsToMove = new Set(selectedItemIds);
@@ -248,7 +248,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
targetSection.kind === "container" targetSection.kind === "container"
? (containerLayout?.width ?? 0) ? (containerLayout?.width ?? 0)
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset)); : getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
const rowCount = containerLayout?.height ?? 9999; const rowCount = containerLayout?.height ?? maxRowCount ?? 9999;
if (columnCount === 0 || rowCount === 0) return previous; if (columnCount === 0 || rowCount === 0) return previous;
const existingContainers = previous.sections const existingContainers = previous.sections
@@ -25,6 +25,7 @@ import { useI18n } from "@homarr/translation/client";
import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import type { EmptySection } from "~/app/[locale]/boards/_types"; import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry";
import { useBoardSelection } from "./board-selection-context"; import { useBoardSelection } from "./board-selection-context";
const MAX_AVATARS_DISPLAYED = 5; const MAX_AVATARS_DISPLAYED = 5;
@@ -40,6 +41,7 @@ export const BoardSelectionToolbar = () => {
moveSelectedItemsToSection, moveSelectedItemsToSection,
} = useBoardSelection(); } = useBoardSelection();
const board = useRequiredBoard(); const board = useRequiredBoard();
const registeredGridEditors = useRegisteredGridEditors();
const currentLayoutId = useCurrentLayout(); const currentLayoutId = useCurrentLayout();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId); const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const t = useI18n(); const t = useI18n();
@@ -96,7 +98,15 @@ export const BoardSelectionToolbar = () => {
.at(0); .at(0);
const placementOptions = [ const placementOptions = [
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []), ...(mainCanvasSection
? [
{
value: mainCanvasSection.id,
label: t("item.create.destination.mainCanvas"),
maxRowCount: null,
},
]
: []),
...board.sections.flatMap((section) => { ...board.sections.flatMap((section) => {
if (section.kind !== "empty") return []; if (section.kind !== "empty") return [];
const lane = getRootSectionLane(section.xOffset); const lane = getRootSectionLane(section.xOffset);
@@ -105,6 +115,7 @@ export const BoardSelectionToolbar = () => {
{ {
value: section.id, value: section.id,
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`), label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
maxRowCount: registeredGridEditors.get(section.id)?.placementMaxRowCount,
}, },
]; ];
}), }),
@@ -113,6 +124,7 @@ export const BoardSelectionToolbar = () => {
.map((section) => ({ .map((section) => ({
value: section.id, value: section.id,
label: section.options.title.trim() || t("section.container.untitled"), label: section.options.title.trim() || t("section.container.untitled"),
maxRowCount: section.layouts.find((layout) => layout.layoutId === currentLayoutId)?.height,
})), })),
]; ];
@@ -244,7 +256,11 @@ export const BoardSelectionToolbar = () => {
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Label>{tSelection("moveTo")}</Menu.Label> <Menu.Label>{tSelection("moveTo")}</Menu.Label>
{placementOptions.map((opt) => ( {placementOptions.map((opt) => (
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}> <Menu.Item
key={opt.value}
disabled={opt.maxRowCount === undefined}
onClick={() => moveSelectedItemsToSection(opt.value, opt.maxRowCount)}
>
{opt.label} {opt.label}
</Menu.Item> </Menu.Item>
))} ))}
+11 -42
View File
@@ -514,7 +514,7 @@ const seedDefaultBoardAsync = async (db: Database) => {
interface DemoWidget { interface DemoWidget {
kind: WidgetKind; kind: WidgetKind;
section?: "network" | "right"; section?: "right";
xOffset: number; xOffset: number;
yOffset: number; yOffset: number;
width: number; width: number;
@@ -665,7 +665,7 @@ const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): D
{ kind: "beszelSystemGrid", xOffset: 5, yOffset: 2, width: 4, height: 3, needsIntegration: true }, { kind: "beszelSystemGrid", xOffset: 5, yOffset: 2, width: 4, height: 3, needsIntegration: true },
{ kind: "assistant", xOffset: 9, yOffset: 2, width: 3, height: 3, needsIntegration: false }, { kind: "assistant", xOffset: 9, yOffset: 2, width: 3, height: 3, needsIntegration: false },
// Around the centered network container // Operations center
{ kind: "mediaRequests-requestList", xOffset: 0, yOffset: 5, width: 3, height: 2, needsIntegration: true }, { kind: "mediaRequests-requestList", xOffset: 0, yOffset: 5, width: 3, height: 2, needsIntegration: true },
{ kind: "mediaMissing", xOffset: 9, yOffset: 5, width: 3, height: 2, needsIntegration: true }, { kind: "mediaMissing", xOffset: 9, yOffset: 5, width: 3, height: 2, needsIntegration: true },
{ kind: "mediaServer", xOffset: 0, yOffset: 7, width: 3, height: 2, needsIntegration: true }, { kind: "mediaServer", xOffset: 0, yOffset: 7, width: 3, height: 2, needsIntegration: true },
@@ -701,21 +701,19 @@ const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): D
options: { definitionId: customWidgetDefinitionId, refreshInterval: 300 }, options: { definitionId: customWidgetDefinitionId, refreshInterval: 300 },
}, },
// Network stuff container // Network and health
{ {
kind: "healthMonitoring", kind: "healthMonitoring",
section: "network", xOffset: 3,
xOffset: 0, yOffset: 5,
yOffset: 0,
width: 6, width: 6,
height: 2, height: 2,
needsIntegration: true, needsIntegration: true,
}, },
{ {
kind: "dnsHoleSummary", kind: "dnsHoleSummary",
section: "network", xOffset: 3,
xOffset: 0, yOffset: 7,
yOffset: 2,
width: 3, width: 3,
height: 2, height: 2,
needsIntegration: true, needsIntegration: true,
@@ -723,15 +721,14 @@ const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): D
}, },
{ {
kind: "beszelSystemStats", kind: "beszelSystemStats",
section: "network", xOffset: 6,
xOffset: 3, yOffset: 7,
yOffset: 2,
width: 3, width: 3,
height: 2, height: 2,
needsIntegration: true, needsIntegration: true,
}, },
{ kind: "notifications", section: "network", xOffset: 0, yOffset: 4, width: 2, height: 2, needsIntegration: true }, { kind: "notifications", xOffset: 3, yOffset: 9, width: 2, height: 2, needsIntegration: true },
{ kind: "beszelAlerts", section: "network", xOffset: 2, yOffset: 4, width: 4, height: 2, needsIntegration: true }, { kind: "beszelAlerts", xOffset: 5, yOffset: 9, width: 4, height: 2, needsIntegration: true },
// Right app rail // Right app rail
...appIds.map( ...appIds.map(
@@ -886,22 +883,6 @@ const seedDemoUserAsync = async (db: Database) => {
boardId, boardId,
}); });
const networkSectionId = createId();
await db.insert(sections).values({
id: networkSectionId,
kind: "container",
boardId,
options: SuperJSON.stringify({
title: "Network stuff",
customCssClasses: [],
borderColor: "",
showLabel: true,
collapsible: false,
showOpenAll: false,
scrollable: false,
}),
});
const layoutId = createId(); const layoutId = createId();
await db.insert(layouts).values({ await db.insert(layouts).values({
id: layoutId, id: layoutId,
@@ -913,23 +894,11 @@ const seedDemoUserAsync = async (db: Database) => {
boardId, boardId,
}); });
await db.insert(sectionLayouts).values({
sectionId: networkSectionId,
layoutId,
parentSectionId: mainSectionId,
xOffset: 3,
yOffset: 5,
width: 6,
height: 6,
});
const demoWidgets = buildDemoWidgets(appIds, customWidgetDefinitionId); const demoWidgets = buildDemoWidgets(appIds, customWidgetDefinitionId);
for (const widget of demoWidgets) { for (const widget of demoWidgets) {
let sectionId = mainSectionId; let sectionId = mainSectionId;
if (widget.section === "right") { if (widget.section === "right") {
sectionId = rightSectionId; sectionId = rightSectionId;
} else if (widget.section === "network") {
sectionId = networkSectionId;
} }
const itemId = createId(); const itemId = createId();
+8 -35
View File
@@ -86,23 +86,8 @@ test("SQLite migrations seed the redesigned demo dashboard", async () => {
const mainSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 0); const mainSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 0);
const rightSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 1); const rightSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 1);
const networkSection = board.sections.find((section) => section.kind === "container"); if (!mainSection || !rightSection) throw new Error("Demo board sections were not seeded");
if (!mainSection || !rightSection || !networkSection) throw new Error("Demo board sections were not seeded"); expect(board.sections.some((section) => section.kind === "container")).toBe(false);
const networkLayout = networkSection.layouts.find((layout) => layout.layoutId === baseLayout.id);
expect(networkLayout).toMatchObject({
parentSectionId: mainSection.id,
xOffset: 3,
yOffset: 5,
width: 6,
height: 6,
});
if (!networkSection.options) throw new Error("Demo network section options were not seeded");
expect(SuperJSON.parse<{ title: string; collapsible: boolean }>(networkSection.options)).toMatchObject({
title: "Network stuff",
collapsible: false,
});
expect(networkSection.collapseStates).toEqual([]);
const itemByKind = (kind: string) => board.items.find((item) => item.kind === kind); const itemByKind = (kind: string) => board.items.find((item) => item.kind === kind);
const expectItemLayout = ( const expectItemLayout = (
@@ -135,11 +120,11 @@ test("SQLite migrations seed the redesigned demo dashboard", async () => {
expectItemLayout("mediaReleases", 4, 11, 4, 3); expectItemLayout("mediaReleases", 4, 11, 4, 3);
expectItemLayout("customApi", 8, 11, 4, 3); expectItemLayout("customApi", 8, 11, 4, 3);
expectItemLayout("healthMonitoring", 0, 0, 6, 2, networkSection.id); expectItemLayout("healthMonitoring", 3, 5, 6, 2);
expectItemLayout("dnsHoleSummary", 0, 2, 3, 2, networkSection.id); expectItemLayout("dnsHoleSummary", 3, 7, 3, 2);
expectItemLayout("beszelSystemStats", 3, 2, 3, 2, networkSection.id); expectItemLayout("beszelSystemStats", 6, 7, 3, 2);
expectItemLayout("notifications", 0, 4, 2, 2, networkSection.id); expectItemLayout("notifications", 3, 9, 2, 2);
expectItemLayout("beszelAlerts", 2, 4, 4, 2, networkSection.id); expectItemLayout("beszelAlerts", 5, 9, 4, 2);
for (const kind of ["weather", "airQuality"]) { for (const kind of ["weather", "airQuality"]) {
const item = itemByKind(kind); const item = itemByKind(kind);
@@ -168,8 +153,7 @@ test("SQLite migrations seed the redesigned demo dashboard", async () => {
const mainItemLayouts = board.items.flatMap((item) => const mainItemLayouts = board.items.flatMap((item) =>
item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === mainSection.id), item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === mainSection.id),
); );
if (!networkLayout) throw new Error("Demo network section layout was not seeded"); expectFilledGrid(mainItemLayouts, 12);
expectFilledGrid([...mainItemLayouts, networkLayout], 12);
const rightRailItems = board.items.filter((item) => const rightRailItems = board.items.filter((item) =>
item.layouts.some((layout) => layout.layoutId === baseLayout.id && layout.sectionId === rightSection.id), item.layouts.some((layout) => layout.layoutId === baseLayout.id && layout.sectionId === rightSection.id),
@@ -205,17 +189,6 @@ test("SQLite migrations seed the redesigned demo dashboard", async () => {
); );
expectFilledGrid(rightRailLayouts, 1); expectFilledGrid(rightRailLayouts, 1);
const networkItems = board.items.filter((item) =>
item.layouts.some((layout) => layout.layoutId === baseLayout.id && layout.sectionId === networkSection.id),
);
expect(networkItems.map((item) => item.kind).toSorted()).toEqual(
["beszelAlerts", "beszelSystemStats", "dnsHoleSummary", "healthMonitoring", "notifications"].toSorted(),
);
const networkItemLayouts = networkItems.flatMap((item) =>
item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === networkSection.id),
);
expectFilledGrid(networkItemLayouts, 6);
const workshopDefinition = await database.query.customWidgetDefinitions.findFirst({ const workshopDefinition = await database.query.customWidgetDefinitions.findFirst({
where: (table, { eq }) => eq(table.creatorId, demoUser.id), where: (table, { eq }) => eq(table.creatorId, demoUser.id),
}); });
@@ -82,7 +82,7 @@ const ArchiveTeamWarriorWidgetContent = ({
</Group> </Group>
{(isAdvanced || options.showBroadcastMessage) && status.broadcastMessage && layout.showBroadcast && ( {(isAdvanced || options.showBroadcastMessage) && status.broadcastMessage && layout.showBroadcast && (
<Card withBorder p="xs"> <Card withBorder p="xs" bg="transparent" style={{ borderColor: neutralSurfaceBorder }}>
<Text size="xs" lineClamp={isAdvanced ? undefined : 3}> <Text size="xs" lineClamp={isAdvanced ? undefined : 3}>
{status.broadcastMessage} {status.broadcastMessage}
</Text> </Text>
@@ -143,7 +143,13 @@ const ArchiveTeamWarriorWidgetContent = ({
{status.items.map((item) => { {status.items.map((item) => {
const itemStatusKey = getStatusKey(item.status); const itemStatusKey = getStatusKey(item.status);
return ( return (
<Card key={item.id} withBorder p="xs"> <Card
key={item.id}
withBorder
p="xs"
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}> <Stack gap={0} miw={0}>
<Text size="sm" fw={600} lineClamp={1}> <Text size="sm" fw={600} lineClamp={1}>
@@ -201,6 +207,9 @@ export const getArchiveCompactLayout = (width: number, height: number, isAdvance
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0)); const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
const neutralSurfaceBorder =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown"; type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown";
const getStatusKey = (status?: string | null): WarriorStatusKey => { const getStatusKey = (status?: string | null): WarriorStatusKey => {
+1 -1
View File
@@ -17,7 +17,7 @@ export default function AssistantWidget(props: WidgetComponentProps<"assistant">
return ( return (
<Center h="100%" p="md"> <Center h="100%" p="md">
<Stack align="center" gap="xs" ta="center"> <Stack align="center" gap="xs" ta="center">
<ThemeIcon variant="light" color="red" radius="xl" size="lg"> <ThemeIcon variant="light" radius="xl" size="lg">
<IconRobot size="var(--mantine-font-size-xl)" /> <IconRobot size="var(--mantine-font-size-xl)" />
</ThemeIcon> </ThemeIcon>
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
@@ -40,7 +40,7 @@
gap: clamp(2px, 1.5cqi, 4px); gap: clamp(2px, 1.5cqi, 4px);
padding: clamp(4px, 2cqi, 8px); padding: clamp(4px, 2cqi, 8px);
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); background-color: transparent;
overflow: hidden; overflow: hidden;
} }
@@ -29,7 +29,7 @@
gap: clamp(2px, 1.5cqi, 5px); gap: clamp(2px, 1.5cqi, 5px);
padding: clamp(4px, 2.5cqi, 10px); padding: clamp(4px, 2.5cqi, 10px);
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); background-color: transparent;
overflow: hidden; overflow: hidden;
} }
@@ -63,7 +63,11 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
}} }}
> >
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}> <Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} /> <Progress
value={system.disk}
color={thresholdColor(system.disk)}
size={trackSize}
/>
<Group <Group
gap={dotGap} gap={dotGap}
wrap="nowrap" wrap="nowrap"
@@ -101,7 +105,12 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
<Text size="sm" fw={600} miw={48} lh={1.25}> <Text size="sm" fw={600} miw={48} lh={1.25}>
{formatPercent(value)} {formatPercent(value)}
</Text> </Text>
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} /> <Progress
value={value}
color={thresholdColor(value)}
size="xs"
style={{ flex: 1 }}
/>
</Group> </Group>
</Stack> </Stack>
))} ))}
@@ -111,7 +120,11 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
) : ( ) : (
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}> <Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}> <Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} /> <Progress
value={system.disk}
color={thresholdColor(system.disk)}
size={trackSize}
/>
</Box> </Box>
</Box> </Box>
)} )}
@@ -1,5 +1,15 @@
.card:hover { .card {
background-color: var(--mantine-color-primaryColor-light-hover); background-color: transparent;
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
transition:
background-color 120ms ease,
border-color 120ms ease;
}
.card:hover,
.card:focus-within {
background-color: transparent;
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.62));
} }
.bookmarkButton:focus-visible { .bookmarkButton:focus-visible {
@@ -8,14 +18,6 @@
border-radius: var(--mantine-radius-sm); border-radius: var(--mantine-radius-sm);
} }
[data-mantine-color-scheme="light"] .card-grid {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card-grid {
background-color: var(--mantine-color-dark-7);
}
.card:hover > div > div.bookmarkIcon { .card:hover > div > div.bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover); background-color: var(--mantine-color-iconColor-filled-hover);
} }
@@ -24,6 +26,12 @@
background-color: var(--mantine-color-iconColor-filled-hover); background-color: var(--mantine-color-iconColor-filled-hover);
} }
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}
.compactScrollViewport > div { .compactScrollViewport > div {
display: flex !important; display: flex !important;
width: 100%; width: 100%;
+30 -15
View File
@@ -3,8 +3,8 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core"; import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconLink } from "@tabler/icons-react"; import { IconLink } from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
@@ -18,6 +18,8 @@ import classes from "./bookmark.module.css";
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"]; type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
const COMPACT_GRID_GAP = 4;
export interface BookmarkDisplayItem { export interface BookmarkDisplayItem {
id: string; id: string;
name: string; name: string;
@@ -30,7 +32,6 @@ export interface CompactBookmarkLayout {
columns: number; columns: number;
hideHostname: boolean; hideHostname: boolean;
hideTitle: boolean; hideTitle: boolean;
minimumItemWidth: number;
minimumItemSize: number; minimumItemSize: number;
} }
@@ -43,9 +44,14 @@ export function getCompactBookmarkLayout(
const count = Math.max(1, itemCount); const count = Math.max(1, itemCount);
const isGrid = layout === "grid" || layout === "gridHorizontal"; const isGrid = layout === "grid" || layout === "gridHorizontal";
const targetWidth = layout === "gridHorizontal" ? 180 : 104; const targetWidth = layout === "gridHorizontal" ? 180 : 104;
const columns = isGrid ? Math.max(1, Math.min(count, Math.floor(width / targetWidth))) : layout === "row" ? count : 1; let columns = 1;
if (isGrid) {
columns = Math.max(1, Math.min(count, Math.floor((width + COMPACT_GRID_GAP) / (targetWidth + COMPACT_GRID_GAP))));
} else if (layout === "row") {
columns = count;
}
const rows = Math.max(1, Math.ceil(count / columns)); const rows = Math.max(1, Math.ceil(count / columns));
const cellWidth = width / columns; const cellWidth = (width - (columns - 1) * COMPACT_GRID_GAP) / columns;
const cellHeight = height / rows; const cellHeight = height / rows;
const usesHorizontalItems = layout === "column" || layout === "gridHorizontal"; const usesHorizontalItems = layout === "column" || layout === "gridHorizontal";
@@ -53,7 +59,6 @@ export function getCompactBookmarkLayout(
columns, columns,
hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96), hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96),
hideTitle: cellWidth < 64 || cellHeight < 40, hideTitle: cellWidth < 64 || cellHeight < 40,
minimumItemWidth: targetWidth,
minimumItemSize: usesHorizontalItems ? 48 : 96, minimumItemSize: usesHorizontalItems ? 48 : 96,
}; };
} }
@@ -66,6 +71,7 @@ export default function BookmarksWidget({
displayMode, displayMode,
}: WidgetComponentProps<"bookmarks">) { }: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard(); const board = useRequiredBoard();
const { ref: compactContentRef, width: compactContentWidth } = useElementSize<HTMLDivElement>();
const apps = const apps =
getUsableWidgetQueryData( getUsableWidgetQueryData(
clientApi.app.byIds.useQuery(options.items, { clientApi.app.byIds.useQuery(options.items, {
@@ -105,8 +111,8 @@ export default function BookmarksWidget({
); );
const compactLayout = useMemo( const compactLayout = useMemo(
() => getCompactBookmarkLayout(width, height, data.length, options.layout), () => getCompactBookmarkLayout(compactContentWidth || width, height, data.length, options.layout),
[width, height, data.length, options.layout], [compactContentWidth, data.length, height, options.layout, width],
); );
const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon); const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon);
const compactHideHostname = const compactHideHostname =
@@ -135,11 +141,11 @@ export default function BookmarksWidget({
scrollbarSize={6} scrollbarSize={6}
style={{ flex: 1, minHeight: 0 }} style={{ flex: 1, minHeight: 0 }}
> >
<div className={classes.compactScrollContent}> <div ref={compactContentRef} className={classes.compactScrollContent}>
{options.layout === "grid" || options.layout === "gridHorizontal" ? ( {options.layout === "grid" || options.layout === "gridHorizontal" ? (
<GridLayout <GridLayout
data={data} data={data}
minimumItemWidth={compactLayout.minimumItemWidth} columns={compactLayout.columns}
minimumItemHeight={compactLayout.minimumItemSize} minimumItemHeight={compactLayout.minimumItemSize}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"} itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={compactHideTitle} hideTitle={compactHideTitle}
@@ -211,6 +217,7 @@ const FlexLayout = ({
<Card <Card
radius={board.itemRadius} radius={board.itemRadius}
className={classes.card} className={classes.card}
bg="transparent"
w="100%" w="100%"
display="flex" display="flex"
p={4} p={4}
@@ -295,7 +302,14 @@ const AdvancedBookmarksLayout = ({
target={href ? (openNewTab ? "_blank" : "_self") : undefined} target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined} rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
> >
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%"> <Card
radius={board.itemRadius}
className={classes.card}
bg="transparent"
withBorder={withBorder}
p="md"
h="100%"
>
<Group align="flex-start" wrap="nowrap"> <Group align="flex-start" wrap="nowrap">
<BookmarkIcon <BookmarkIcon
app={app} app={app}
@@ -335,7 +349,7 @@ interface GridLayoutProps {
withBorder: boolean; withBorder: boolean;
itemDirection: "horizontal" | "vertical"; itemDirection: "horizontal" | "vertical";
hasIconColor: boolean; hasIconColor: boolean;
minimumItemWidth: number; columns: number;
minimumItemHeight: number; minimumItemHeight: number;
} }
@@ -348,13 +362,13 @@ const GridLayout = ({
withBorder, withBorder,
itemDirection, itemDirection,
hasIconColor, hasIconColor,
minimumItemWidth, columns,
minimumItemHeight, minimumItemHeight,
}: GridLayoutProps) => { }: GridLayoutProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
return ( return (
<Flex gap={4} wrap="wrap" miw="100%" mih="100%" style={{ alignContent: "stretch" }}> <Flex gap={COMPACT_GRID_GAP} wrap="wrap" miw="100%" mih="100%" style={{ alignContent: "stretch" }}>
{data.map((app) => { {data.map((app) => {
const href = getSafeAppHref(app.href); const href = getSafeAppHref(app.href);
return ( return (
@@ -366,14 +380,15 @@ const GridLayout = ({
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined} rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
key={app.id} key={app.id}
style={{ style={{
flex: `1 0 calc(${minimumItemWidth}px * var(--mantine-scale, 1))`, flex: `1 0 calc((100% - calc(${COMPACT_GRID_GAP * (columns - 1)}px * var(--mantine-scale, 1))) / ${columns})`,
minWidth: 0, minWidth: 0,
minHeight: `calc(${minimumItemHeight}px * var(--mantine-scale, 1))`, minHeight: `calc(${minimumItemHeight}px * var(--mantine-scale, 1))`,
}} }}
> >
<Card <Card
h="100%" h="100%"
className={combineClasses(classes.card, classes["card-grid"])} className={classes.card}
bg="transparent"
radius={board.itemRadius} radius={board.itemRadius}
withBorder={withBorder} withBorder={withBorder}
p="xs" p="xs"
@@ -3,3 +3,13 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.errorIndicator {
top: calc(var(--mantine-spacing-md) + var(--mantine-spacing-xl));
inset-inline-end: 4px;
z-index: 1;
padding: 2px;
border: 1px solid rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * 0.45));
border-radius: var(--mantine-radius-sm);
background-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.18));
}
+10 -8
View File
@@ -180,14 +180,10 @@ const CalendarBase = ({
); );
} }
const hasErrors = failedIntegrations.length > 0 || Boolean(queryError);
return ( return (
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}> <Box ref={ref} h="100%" w="100%" pos="relative" style={{ overflow: "hidden" }}>
{(failedIntegrations.length > 0 || Boolean(queryError)) && (
<Group px="xs" justify="flex-end">
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
</Group>
)}
<Calendar <Calendar
defaultDate={new Date()} defaultDate={new Date()}
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))} onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
@@ -260,7 +256,13 @@ const CalendarBase = ({
); );
}} }}
/> />
</Stack> {hasErrors && (
<Group className={classes.errorIndicator} gap={0} pos="absolute">
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
</Group>
)}
</Box>
); );
}; };
@@ -135,14 +135,13 @@ export function HomarrDataTable<T>({
withTableBorder={false} withTableBorder={false}
borderRadius={0} borderRadius={0}
highlightOnHover highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }} highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center" verticalAlign="center"
loaderBackgroundBlur={2} loaderBackgroundBlur={2}
textSelectionDisabled textSelectionDisabled
height="100%" height="100%"
{...props} {...props}
striped={false}
columns={fittedColumns} columns={fittedColumns}
onMouseDownCapture={handleMouseDownCapture} onMouseDownCapture={handleMouseDownCapture}
className={mergedClassName} className={mergedClassName}
@@ -0,0 +1,29 @@
.accordion {
--coolify-neutral-fill: rgb(
from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.12)
);
--coolify-neutral-border: rgb(
from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)
);
}
.accordion :global(.mantine-Accordion-item) {
--item-border-color: var(--coolify-neutral-border);
--item-filled-color: var(--coolify-neutral-fill);
}
.accordion :global(.mantine-Accordion-control) {
background-color: transparent;
}
.accordion :global(.mantine-Accordion-control:not(:disabled, [data-disabled]):hover) {
background-color: var(--coolify-neutral-fill);
}
.neutralDividerBottom {
border-bottom: 1px solid var(--coolify-neutral-border);
}
.neutralDividerTop {
border-top: 1px solid var(--coolify-neutral-border);
}
@@ -8,6 +8,7 @@ import { useTimeAgo } from "@homarr/common";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section"; import { ApplicationsSection } from "./applications-section";
import classes from "./component.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { buildServerResourceCounts, getBadgeColor, isCoolifyServerOnline, parseStatus } from "./coolify-utils"; import { buildServerResourceCounts, getBadgeColor, isCoolifyServerOnline, parseStatus } from "./coolify-utils";
import { ServersSection } from "./servers-section"; import { ServersSection } from "./servers-section";
@@ -71,12 +72,12 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
.join(" · "); .join(" · ");
return ( return (
<Card p={0} radius="sm"> <Card p={0} radius="sm" bg="transparent">
<Group <Group
p="xs" p="xs"
justify="space-between" justify="space-between"
wrap="nowrap" wrap="nowrap"
style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }} className={classes.neutralDividerBottom}
> >
<Group gap={4} wrap="nowrap" miw={0}> <Group gap={4} wrap="nowrap" miw={0}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} /> <Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
@@ -135,6 +136,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
</Group> </Group>
<Accordion <Accordion
className={classes.accordion}
variant="filled" variant="filled"
chevronPosition="right" chevronPosition="right"
multiple multiple
@@ -175,11 +177,7 @@ const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Da
const t = useI18n("widget.coolify"); const t = useI18n("widget.coolify");
const relativeTime = useTimeAgo(updatedAt, 60_000); const relativeTime = useTimeAgo(updatedAt, 60_000);
return ( return (
<Group <Group justify="space-between" p={4} className={classes.neutralDividerTop}>
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Text size="10px" c="dimmed"> <Text size="10px" c="dimmed">
v{version} v{version}
</Text> </Text>
@@ -8,6 +8,7 @@ import { useTimeAgo } from "@homarr/common";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section"; import { ApplicationsSection } from "./applications-section";
import classes from "./component.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { buildServerResourceCounts } from "./coolify-utils"; import { buildServerResourceCounts } from "./coolify-utils";
import { ServersSection } from "./servers-section"; import { ServersSection } from "./servers-section";
@@ -82,6 +83,7 @@ export function SingleInstanceLayout({
</Group> </Group>
<Accordion <Accordion
className={classes.accordion}
variant="contained" variant="contained"
chevronPosition="right" chevronPosition="right"
multiple multiple
@@ -123,11 +125,7 @@ const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Da
const t = useI18n("widget.coolify"); const t = useI18n("widget.coolify");
const relativeTime = useTimeAgo(updatedAt, 60_000); const relativeTime = useTimeAgo(updatedAt, 60_000);
return ( return (
<Group <Group justify="space-between" p={4} className={classes.neutralDividerTop}>
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Group gap={2}> <Group gap={2}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} /> <Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
@@ -1,7 +1,3 @@
[data-mantine-color-scheme="light"] .card { .card {
background-color: var(--mantine-color-gray-1); background-color: transparent;
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
} }
@@ -195,6 +195,9 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const label = translateIfNecessary(t, item.label); const label = translateIfNecessary(t, item.label);
const value = item.value(data, width <= 64 ? "sm" : "md"); const value = item.value(data, width <= 64 ? "sm" : "md");
const backgroundColor = usePiHoleColors
? `rgb(from ${item.color} r g b / calc(var(--opacity, 1) * 0.4))`
: "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
return ( return (
<Tooltip label={tooltip} disabled={!tooltip} w={250} multiline events={{ hover: true, focus: true, touch: true }}> <Tooltip label={tooltip} disabled={!tooltip} w={250} multiline events={{ hover: true, focus: true, touch: true }}>
@@ -206,9 +209,11 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
className="summary-card" className="summary-card"
p="sm" p="sm"
radius={board.itemRadius} radius={board.itemRadius}
bg={usePiHoleColors ? item.color : "var(--mantine-color-default-hover)"} bg={backgroundColor}
style={{ style={{
flex: 1, flex: 1,
border:
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))",
}} }}
> >
<Flex <Flex
+6 -2
View File
@@ -1,5 +1,7 @@
"use client"; "use client";
import "./styles.css";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
ActionIcon, ActionIcon,
@@ -146,8 +148,9 @@ const createColumns = (
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}> <Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
{inlineState && <ContainerStateDot state={container.state} />} {inlineState && <ContainerStateDot state={container.state} />}
<Avatar <Avatar
radius="xl" variant="outline"
size={24} radius="sm"
size={20}
styles={{ image: { objectFit: "contain" } }} styles={{ image: { objectFit: "contain" } }}
src={container.iconUrl} src={container.iconUrl}
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
@@ -432,6 +435,7 @@ export default function DockerWidget({
<HomarrDataTable <HomarrDataTable
isEditMode={isEditMode} isEditMode={isEditMode}
cellPadding="2px 8px" cellPadding="2px 8px"
className="docker-table"
rowCursor="default" rowCursor="default"
fetching={isFetching && containers.length === 0} fetching={isFetching && containers.length === 0}
fz={width < 400 ? "xs" : "sm"} fz={width < 400 ? "xs" : "sm"}
+5
View File
@@ -0,0 +1,5 @@
/* Keep Docker rows translucent so board wallpaper and the inherited item opacity remain visible. */
.homarr-data-table.docker-table {
--mantine-datatable-background-color: transparent !important;
--mantine-datatable-striped-color: transparent !important;
}
+2 -8
View File
@@ -143,11 +143,6 @@ const stateColorMap: Record<DownloadState, string> = {
unknown: "gray", unknown: "gray",
}; };
const rowBgAlpha: Partial<Record<DownloadState, string>> = {
failed: "var(--mantine-color-red-light)",
paused: "var(--mantine-color-yellow-light)",
};
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = { const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns, upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
ratio: (ctx) => ctx.hasTorrents, ratio: (ctx) => ctx.hasTorrents,
@@ -606,7 +601,7 @@ export default function DownloadClientsWidget({
title: t("items.state.columnTitle"), title: t("items.state.columnTitle"),
width: 90, width: 90,
render: (record) => ( render: (record) => (
<Badge size="xs" variant="light" color={stateColorMap[record.state]}> <Badge size="xs" variant="dot" color={stateColorMap[record.state]}>
{t(`states.${record.state}`)} {t(`states.${record.state}`)}
</Badge> </Badge>
), ),
@@ -784,7 +779,6 @@ export default function DownloadClientsWidget({
className="downloads-table" className="downloads-table"
customRowAttributes={(_, index) => ({ "data-row-index": index })} customRowAttributes={(_, index) => ({ "data-row-index": index })}
onRowContextMenu={rowContextMenuHandler} onRowContextMenu={rowContextMenuHandler}
rowBackgroundColor={(record) => rowBgAlpha[record.state]}
rowExpansion={{ rowExpansion={{
trigger: "click", trigger: "click",
allowMultiple: true, allowMultiple: true,
@@ -900,7 +894,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
<Text size="sm" fw={600} truncate style={{ minWidth: 0 }}> <Text size="sm" fw={600} truncate style={{ minWidth: 0 }}>
{item.name} {item.name}
</Text> </Text>
<Badge size="xs" variant="light" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}> <Badge size="xs" variant="dot" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}>
{t(`states.${item.state}`)} {t(`states.${item.state}`)}
</Badge> </Badge>
</Group> </Group>
+3 -1
View File
@@ -27,6 +27,7 @@ import type { WidgetComponentProps } from "../definition";
import { calculateBandwidth, formatBitsPerSec } from "./bandwidth"; import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
import { FirewallMenu } from "./firewall-menu"; import { FirewallMenu } from "./firewall-menu";
import { FirewallVersion, formatVersion } from "./firewall-version"; import { FirewallVersion, formatVersion } from "./firewall-version";
import classes from "./firewall.module.css";
export interface Firewall { export interface Firewall {
label: string; label: string;
@@ -243,7 +244,7 @@ const FirewallPanel = ({
t, t,
tCommon, tCommon,
}: FirewallPanelProps) => ( }: FirewallPanelProps) => (
<Paper withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0}> <Paper className={classes.card} withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0} bg="transparent">
<Stack gap="xs"> <Stack gap="xs">
{isAdvanced && ( {isAdvanced && (
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
@@ -382,6 +383,7 @@ const InterfacesPanel = ({
value={isAdvanced ? "interfaces" : accordionValue} value={isAdvanced ? "interfaces" : accordionValue}
onChange={isAdvanced ? undefined : setAccordionValue} onChange={isAdvanced ? undefined : setAccordionValue}
variant={isAdvanced ? "contained" : "default"} variant={isAdvanced ? "contained" : "default"}
classNames={{ item: classes.accordionItem, control: classes.accordionControl }}
> >
<Accordion.Item value="interfaces"> <Accordion.Item value="interfaces">
<Accordion.Control icon={<IconTopologyBus size="var(--mantine-font-size-md)" />}> <Accordion.Control icon={<IconTopologyBus size="var(--mantine-font-size-md)" />}>
@@ -1,6 +1,7 @@
import { Box, Select } from "@mantine/core"; import { Box, Select } from "@mantine/core";
import type { Firewall } from "./component"; import type { Firewall } from "./component";
import classes from "./firewall.module.css";
interface FirewallMenuProps { interface FirewallMenuProps {
onChange: (value: string | null) => void; onChange: (value: string | null) => void;
@@ -18,6 +19,7 @@ export const FirewallMenu = ({ onChange, isTiny, dropdownItems, selectedFirewall
w="100%" w="100%"
color="lightgray" color="lightgray"
data={dropdownItems} data={dropdownItems}
classNames={{ input: classes.selectInput }}
styles={{ styles={{
input: { input: {
minHeight: "24px", minHeight: "24px",
@@ -1,7 +1,33 @@
[data-mantine-color-scheme="light"] .card { .card {
background-color: var(--mantine-color-gray-1); background-color: transparent;
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
} }
[data-mantine-color-scheme="dark"] .card { .accordionItem {
background-color: var(--mantine-color-dark-7); border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent;
}
.accordionItem[data-active] {
background-color: transparent;
}
.accordionControl {
background-color: transparent;
}
.accordionControl:hover,
.accordionControl:focus-visible,
.accordionControl:active {
background-color: rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.4));
}
.selectInput {
background-color: transparent;
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
}
.selectInput:focus,
.selectInput:focus-within {
border-color: var(--mantine-primary-color-filled);
} }
@@ -1,9 +1,5 @@
[data-mantine-color-scheme="light"] .card { .card {
background-color: var(--mantine-color-gray-1); background-color: transparent;
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
} }
.systemPanel { .systemPanel {
@@ -191,7 +191,7 @@ export const SystemHealthMonitoring = ({
))} ))}
</Flex> </Flex>
{isAdvanced && ( {isAdvanced && (
<Card radius={board.itemRadius} p="sm"> <Card className={classes.card} radius={board.itemRadius} p="sm">
<SystemInformationList healthInfo={healthInfo} memoryUsage={memoryUsage} t={t} compact /> <SystemInformationList healthInfo={healthInfo} memoryUsage={memoryUsage} t={t} compact />
</Card> </Card>
)} )}
@@ -1,13 +1,22 @@
.statItem { .statItem {
padding: 12px; padding: 8px 10px;
border-radius: var(--mantine-radius-md); border: 0.0625rem solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); border-radius: var(--mantine-radius-sm);
background-color: transparent;
} }
.statItemDense { .statItemDense {
min-width: 0; min-width: 0;
min-height: 44px; min-height: 40px;
padding: 4px 6px; padding: 3px 6px;
border: 0.0625rem solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.32));
border-radius: var(--mantine-radius-sm); border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); background-color: transparent;
}
@media (forced-colors: active) {
.statItem,
.statItemDense {
border-color: CanvasText;
}
} }
@@ -55,42 +55,49 @@ export default function ImmichServerStatsWidget({
Number(statVisibility.showStorage); Number(statVisibility.showStorage);
const statsLayout = getImmichStatsLayout(width, height, statCount, isAdvanced); const statsLayout = getImmichStatsLayout(width, height, statCount, isAdvanced);
const statsContent = ( const statItems = [
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}> {
{statVisibility.showUsers && ( key: "users",
<StatItem enabled: statVisibility.showUsers,
icon={<IconUsers size="var(--mantine-font-size-xl)" />} icon: <IconUsers size={statsLayout.dense ? 16 : 18} />,
label={t("users")} label: t("users"),
value={stats.userCount.toLocaleString(locale)} value: stats.userCount.toLocaleString(locale),
dense={statsLayout.dense} },
/> {
)} key: "photos",
{statVisibility.showPhotos && ( enabled: statVisibility.showPhotos,
<StatItem icon: <IconPhoto size={statsLayout.dense ? 16 : 18} />,
icon={<IconPhoto size="var(--mantine-font-size-xl)" />} label: t("photos"),
label={t("photos")} value: stats.photoCount.toLocaleString(locale),
value={stats.photoCount.toLocaleString(locale)} },
dense={statsLayout.dense} {
/> key: "videos",
)} enabled: statVisibility.showVideos,
{statVisibility.showVideos && ( icon: <IconVideo size={statsLayout.dense ? 16 : 18} />,
<StatItem label: t("videos"),
icon={<IconVideo size="var(--mantine-font-size-xl)" />} value: stats.videoCount.toLocaleString(locale),
label={t("videos")} },
value={stats.videoCount.toLocaleString(locale)} {
dense={statsLayout.dense} key: "storage",
/> enabled: statVisibility.showStorage,
)} icon: <IconDatabase size={statsLayout.dense ? 16 : 18} />,
{statVisibility.showStorage && ( label: t("storage"),
<StatItem value: formatBytes(stats.totalLibraryUsageInBytes),
icon={<IconDatabase size="var(--mantine-font-size-xl)" />} },
label={t("storage")} ]
value={formatBytes(stats.totalLibraryUsageInBytes)} .filter((item) => item.enabled)
dense={statsLayout.dense} .map(({ key, icon, label, value }) => (
/> <StatItem key={key} icon={icon} label={label} value={value} dense={statsLayout.dense} />
)} ));
</SimpleGrid>
); const statsContent =
isAdvanced || statsLayout.dense ? (
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
{statItems}
</SimpleGrid>
) : (
<Stack gap="xs">{statItems}</Stack>
);
if (!isAdvanced) { if (!isAdvanced) {
return ( return (
@@ -1,9 +1,6 @@
[data-mantine-color-scheme="light"] .card { .card {
background-color: var(--mantine-color-gray-1); background-color: transparent;
} border: 1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
} }
.root { .root {
@@ -31,7 +28,7 @@
&:hover, &:hover,
&:focus-within { &:focus-within {
background-color: var(--mantine-color-default-hover); background-color: transparent;
} }
} }
@@ -4,23 +4,19 @@
display: flex; display: flex;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
border: 1px solid var(--border-color); border: 1px solid
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent;
transition: transition:
border-color 120ms ease, border-color 120ms ease,
background-color 120ms ease; background-color 120ms ease;
@mixin dark {
--border-color: var(--mantine-color-dark-4);
}
@mixin light {
--border-color: var(--mantine-color-gray-3);
}
&:hover, &:hover,
&:focus-visible { &:focus-visible {
border-color: var(--mantine-primary-color-filled); border-color: var(--mantine-primary-color-filled);
background-color: var(--mantine-color-default-hover); background-color: rgb(
from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.14)
);
} }
&:focus-visible { &:focus-visible {
@@ -29,6 +25,16 @@
} }
} }
:global(.mediaMissing-wrapper) :global(.mantine-SimpleGrid-root) > :global(.mantine-Paper-root) {
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent;
}
:global(.mediaMissing-wrapper) :global(.mantine-Tabs-list) {
--tab-border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
--tab-hover-color: rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.14));
}
.content { .content {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -1,7 +1,7 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
Badge, Badge,
Box, Box,
@@ -36,11 +36,37 @@ import classes from "./component.module.css";
import type { MediaMissingTab } from "./tabs"; import type { MediaMissingTab } from "./tabs";
import { resolveMediaMissingTab } from "./tabs"; import { resolveMediaMissingTab } from "./tabs";
type Density = "thin" | "compact" | "comfortable";
const MIN_CARD_WIDTH = 280;
const MAX_COLUMNS = 4;
const GRID_HORIZONTAL_PADDING = 20;
const GRID_COLUMN_GAP = 10;
const columnBreakpoints = Array.from({ length: MAX_COLUMNS }, (_, index) => {
const columns = MAX_COLUMNS - index;
return {
columns,
minimumWidth: columns * MIN_CARD_WIDTH + (columns - 1) * GRID_COLUMN_GAP,
};
});
const getMediaMissingGridLayout = (panelWidth: number) => {
const contentWidth = Math.max(0, panelWidth - GRID_HORIZONTAL_PADDING);
const breakpoint = columnBreakpoints.find((candidate) => contentWidth >= candidate.minimumWidth);
return { columns: breakpoint?.columns ?? 1, contentWidth };
};
const getMediaMissingDensity = (contentWidth: number, columns: number, isThin: boolean): Density => {
if (isThin) return "thin";
if (contentWidth > 0 && contentWidth / columns < 180) return "compact";
return "comfortable";
};
export default function MediaMissingWidget({ export default function MediaMissingWidget({
integrationIds, integrationIds,
options, options,
width, width,
height,
displayMode, displayMode,
widgetRuntimeRef, widgetRuntimeRef,
}: WidgetComponentProps<"mediaMissing">) { }: WidgetComponentProps<"mediaMissing">) {
@@ -68,6 +94,12 @@ export default function MediaMissingWidget({
if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab); if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab);
}, [activeTab, selectedTab]); }, [activeTab, selectedTab]);
const enabledPanelCount = Number(showMissing) + Number(showQueued);
const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
const gridLayout = useMemo(() => getMediaMissingGridLayout(panelWidth), [panelWidth]);
const density = getMediaMissingDensity(gridLayout.contentWidth, gridLayout.columns, isThin);
if (!data) return <WidgetEmptyState />; if (!data) return <WidgetEmptyState />;
if (data.length === 0) throw new NoIntegrationDataError(); if (data.length === 0) throw new NoIntegrationDataError();
if (!showMissing && !showQueued) if (!showMissing && !showQueued)
@@ -85,14 +117,6 @@ export default function MediaMissingWidget({
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0); const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error)); const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error));
const enabledPanelCount = Number(showMissing) + Number(showQueued);
const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
const isShort = !isAdvanced && height > 0 && height < 180;
const targetCardWidth = isShort ? 130 : 200;
const columns = panelWidth > 0 ? Math.max(1, Math.min(Math.floor(panelWidth / targetCardWidth), 4)) : 1;
const density: Density = isThin ? "thin" : panelWidth > 0 && panelWidth / columns < 180 ? "compact" : "comfortable";
const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`); const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`);
const renderPanel = ( const renderPanel = (
@@ -114,7 +138,7 @@ export default function MediaMissingWidget({
{emptyLabel} {emptyLabel}
</Text> </Text>
) : ( ) : (
<SimpleGrid cols={columns} spacing="xs" verticalSpacing="xs"> <SimpleGrid cols={Math.min(gridLayout.columns, entries.length)} spacing="xs" verticalSpacing="xs">
{entries.map(({ item, integrationId }) => ( {entries.map(({ item, integrationId }) => (
<MediaCard <MediaCard
key={`${integrationId}-${item.type}-${item.id}`} key={`${integrationId}-${item.type}-${item.id}`}
@@ -226,8 +250,6 @@ export default function MediaMissingWidget({
); );
} }
type Density = "thin" | "compact" | "comfortable";
const CARD_HEIGHT: Record<Density, number> = { thin: 52, compact: 56, comfortable: 68 }; const CARD_HEIGHT: Record<Density, number> = { thin: 52, compact: 56, comfortable: 68 };
const posterSizes: Record<Density, number> = { thin: 34, compact: 40, comfortable: 52 }; const posterSizes: Record<Density, number> = { thin: 34, compact: 40, comfortable: 52 };
@@ -1,14 +1,15 @@
.card { .card {
min-width: 0; min-width: 0;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); background-color: transparent;
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
transition: transition:
background-color 120ms ease, background-color 120ms ease,
border-color 120ms ease; border-color 120ms ease;
&:hover, &:hover,
&:focus-within { &:focus-within {
border-color: var(--mantine-primary-color-filled); border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.62));
background-color: var(--mantine-color-default-hover); background-color: transparent;
} }
} }
@@ -149,7 +149,7 @@ const MediaRequestCard = ({
<Image <Image
className="mediaRequests-list-item-poster" className="mediaRequests-list-item-poster"
src={request.posterImagePath} src={request.posterImagePath}
h={isDense ? 36 : 44} h={isDense ? 32 : 40}
w="auto" w="auto"
radius="sm" radius="sm"
alt="" alt=""
@@ -157,7 +157,7 @@ const MediaRequestCard = ({
/> />
)} )}
<Stack gap={2} w="100%" miw={0}> <Stack gap={0} w="100%" miw={0}>
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-top-group"> <Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-top-group">
<Anchor <Anchor
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title" className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
@@ -274,7 +274,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
className={`mediaRequests-list-item-pending-button-approve ${actionTargetClasses.root}`} className={`mediaRequests-list-item-pending-button-approve ${actionTargetClasses.root}`}
variant="light" variant="light"
color="green" color="green"
size="sm" size="xs"
disabled={!canInteract || isPending} disabled={!canInteract || isPending}
aria-label={t("pending.approve")} aria-label={t("pending.approve")}
onClick={() => { onClick={() => {
@@ -289,7 +289,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
className={`mediaRequests-list-item-pending-button-decline ${actionTargetClasses.root}`} className={`mediaRequests-list-item-pending-button-decline ${actionTargetClasses.root}`}
variant="light" variant="light"
color="red" color="red"
size="sm" size="xs"
disabled={!canInteract || isPending} disabled={!canInteract || isPending}
aria-label={t("pending.decline")} aria-label={t("pending.decline")}
onClick={() => { onClick={() => {
@@ -1,9 +1,5 @@
[data-mantine-color-scheme="light"] .card { .card {
background-color: var(--mantine-color-gray-1); background-color: transparent;
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
} }
.userCard { .userCard {
@@ -13,7 +9,7 @@
&:hover, &:hover,
&:focus-visible { &:focus-visible {
background-color: var(--mantine-color-default-hover); background-color: transparent;
} }
&:focus-visible { &:focus-visible {
@@ -1,21 +1,20 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { getQueryKey } from "@trpc/react-query"; import { getQueryKey } from "@trpc/react-query";
import { import {
Box, Box,
Center, Button,
Divider, Divider,
Group, Group,
Pagination, Pagination,
Paper, Paper,
ScrollArea, ScrollArea,
SegmentedControl, Select,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
VisuallyHidden,
} from "@mantine/core"; } from "@mantine/core";
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react"; import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
@@ -44,6 +43,24 @@ const viewIcons = {
statistics: IconReportAnalytics, statistics: IconReportAnalytics,
} satisfies Record<View, TablerIcon>; } satisfies Record<View, TablerIcon>;
type TranscodingControlMode = "buttons" | "select";
const narrowWidthLayout = { minimumWidth: 0, advancedColumns: 1, controlMode: "select" } as const;
const widthBreakpoints = [
{ minimumWidth: 1100, advancedColumns: 3, controlMode: "buttons" },
{ minimumWidth: 700, advancedColumns: 2, controlMode: "buttons" },
{ minimumWidth: 560, advancedColumns: 1, controlMode: "buttons" },
narrowWidthLayout,
] as const satisfies readonly {
minimumWidth: number;
advancedColumns: number;
controlMode: TranscodingControlMode;
}[];
const getTranscodingWidthLayout = (width: number) =>
widthBreakpoints.find((candidate) => width >= candidate.minimumWidth) ?? narrowWidthLayout;
export default function MediaTranscodingWidget({ export default function MediaTranscodingWidget({
integrationIds, integrationIds,
options, options,
@@ -91,6 +108,7 @@ export default function MediaTranscodingWidget({
const handleQueuePageChange = (page: number) => { const handleQueuePageChange = (page: number) => {
setQueuePagination({ page, pageSize: queuePageSize, isAdvanced }); setQueuePagination({ page, pageSize: queuePageSize, isAdvanced });
}; };
const widthLayout = useMemo(() => getTranscodingWidthLayout(width), [width]);
if (!transcodingData) return <WidgetEmptyState />; if (!transcodingData) return <WidgetEmptyState />;
@@ -108,7 +126,7 @@ export default function MediaTranscodingWidget({
<Stack gap="xs" h="100%" p="xs" pos="relative"> <Stack gap="xs" h="100%" p="xs" pos="relative">
{queryIndicator} {queryIndicator}
<ScrollArea h="100%" style={{ flex: 1 }}> <ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={width >= 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm"> <SimpleGrid cols={widthLayout.advancedColumns} spacing="sm">
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}> <AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
<WorkersPanel workers={transcodingData.data.workers} isTiny={false} /> <WorkersPanel workers={transcodingData.data.workers} isTiny={false} />
</AdvancedPanel> </AdvancedPanel>
@@ -147,35 +165,45 @@ export default function MediaTranscodingWidget({
)} )}
<Divider /> <Divider />
<Group gap="xs" mb={4} ms={4} me={8} wrap="nowrap"> <Group gap="xs" mb={4} ms={4} me={8} wrap="nowrap">
<SegmentedControl {widthLayout.controlMode === "buttons" ? (
data={views.map((value) => { <Button.Group style={{ flex: 1, minWidth: 0 }}>
const Icon = viewIcons[value]; {views.map((value) => {
return { const Icon = viewIcons[value];
label: ( return (
<Center style={{ gap: 4 }}> <Button
<Icon size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} /> key={value}
{footerLayout.showTabLabels ? ( size="compact-xs"
<Text span size="xs"> variant={value === view ? "filled" : "default"}
{t(`tab.${value}`)} leftSection={<Icon size="var(--mantine-font-size-xs)" />}
</Text> aria-pressed={value === view}
) : ( style={{ flex: 1 }}
<VisuallyHidden>{t(`tab.${value}`)}</VisuallyHidden> onClick={() => setView(value)}
)} >
</Center> {t(`tab.${value}`)}
), </Button>
);
})}
</Button.Group>
) : (
<Select
size="xs"
value={view}
data={views.map((value) => ({
value, value,
}; label: t(`tab.${value}`),
})} }))}
value={view} aria-label={t("option.defaultView.label")}
onChange={(value) => { style={{ flex: 1, minWidth: 0 }}
const nextView = viewBySegmentValue[value]; onChange={(value) => {
if (nextView) { if (!value) return;
setView(nextView);
} const nextView = viewBySegmentValue[value];
}} if (nextView) {
size="xs" setView(nextView);
style={{ minWidth: 0, flexShrink: footerLayout.showTabLabels ? 1 : 0 }} }
/> }}
/>
)}
<Group gap="xs" ml="auto" wrap="nowrap"> <Group gap="xs" ml="auto" wrap="nowrap">
{view === "queue" && queuePageCount > 1 && ( {view === "queue" && queuePageCount > 1 && (
@@ -97,7 +97,7 @@ function StatisticItem(props: StatisticItemProps) {
const board = useRequiredBoard(); const board = useRequiredBoard();
return ( return (
<Tooltip label={props.label}> <Tooltip label={props.label}>
<Card p={0} radius={board.itemRadius} miw={48} flex={1}> <Card p={0} radius={board.itemRadius} miw={48} flex={1} bg="transparent">
<Group justify="center" align="center" gap="xs" w="100%" wrap="nowrap"> <Group justify="center" align="center" gap="xs" w="100%" wrap="nowrap">
<props.icon size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} /> <props.icon size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} />
<Text size="md">{props.value}</Text> <Text size="md">{props.value}</Text>
@@ -14,6 +14,9 @@ import { getNetworkControllerStatusLayout } from "./layout";
import { WifiVariant } from "./variants/wifi-variant"; import { WifiVariant } from "./variants/wifi-variant";
import { WiredVariant } from "./variants/wired-variant"; import { WiredVariant } from "./variants/wired-variant";
const neutralBorderColor =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
export default function NetworkControllerNetworkStatusWidget({ export default function NetworkControllerNetworkStatusWidget({
options, options,
integrationIds, integrationIds,
@@ -69,7 +72,7 @@ export default function NetworkControllerNetworkStatusWidget({
{queryIndicators} {queryIndicators}
<SimpleGrid cols={summaries.length > 1 ? layout.sourceColumns : 1} spacing="sm"> <SimpleGrid cols={summaries.length > 1 ? layout.sourceColumns : 1} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => ( {summaries.map(({ integration, summary, updatedAt }) => (
<Card key={integration.id} withBorder p="sm"> <Card key={integration.id} withBorder p="sm" bg="transparent" style={{ borderColor: neutralBorderColor }}>
<Stack gap="sm"> <Stack gap="sm">
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}> <Stack gap={0} miw={0}>
@@ -87,10 +90,20 @@ export default function NetworkControllerNetworkStatusWidget({
</Badge> </Badge>
</Group> </Group>
<SimpleGrid cols={layout.columns} spacing="sm"> <SimpleGrid cols={layout.columns} spacing="sm">
<Card p={layout.cardPadding} withBorder> <Card
p={layout.cardPadding}
withBorder
bg="transparent"
style={{ borderColor: neutralBorderColor }}
>
<WifiVariant countGuests={summary.wifi.guests} countUsers={summary.wifi.users} /> <WifiVariant countGuests={summary.wifi.guests} countUsers={summary.wifi.users} />
</Card> </Card>
<Card p={layout.cardPadding} withBorder> <Card
p={layout.cardPadding}
withBorder
bg="transparent"
style={{ borderColor: neutralBorderColor }}
>
<WiredVariant countGuests={summary.lan.guests} countUsers={summary.lan.users} /> <WiredVariant countGuests={summary.lan.guests} countUsers={summary.lan.users} />
</Card> </Card>
</SimpleGrid> </SimpleGrid>
@@ -105,9 +118,15 @@ export default function NetworkControllerNetworkStatusWidget({
return ( return (
<Stack p={layout.padding} h="100%" gap={0}> <Stack p={layout.padding} h="100%" gap={0}>
{queryIndicators} {queryIndicators}
<SimpleGrid cols={layout.columns} h="100%" spacing="sm"> <SimpleGrid cols={layout.columns} spacing="sm" style={{ flex: 1, alignItems: "stretch" }}>
{layout.showWifi && ( {layout.showWifi && (
<Card p={layout.cardPadding} withBorder={layout.withBorder}> <Card
p={layout.cardPadding}
withBorder={layout.withBorder}
bg="transparent"
h="100%"
style={{ borderColor: neutralBorderColor }}
>
<WifiVariant <WifiVariant
countGuests={countWifiGuests} countGuests={countWifiGuests}
countUsers={countWifiUsers} countUsers={countWifiUsers}
@@ -117,7 +136,13 @@ export default function NetworkControllerNetworkStatusWidget({
</Card> </Card>
)} )}
{layout.showWired && ( {layout.showWired && (
<Card p={layout.cardPadding} withBorder={layout.withBorder}> <Card
p={layout.cardPadding}
withBorder={layout.withBorder}
bg="transparent"
h="100%"
style={{ borderColor: neutralBorderColor }}
>
<WiredVariant <WiredVariant
countGuests={countLanGuests} countGuests={countLanGuests}
countUsers={countLanUsers} countUsers={countLanUsers}
@@ -10,11 +10,11 @@ export const StatRow = ({
compact?: boolean; compact?: boolean;
}) => { }) => {
return ( return (
<Stack gap={0}> <Stack gap={2} align="center" justify="center">
<Text size={compact ? "xl" : "2xl"} fw={800} lh={1}> <Text size={compact ? "xl" : "2xl"} fw={800} lh={1} ta="center">
{value} {value}
</Text> </Text>
<Text size={compact ? "sm" : "md"} c="dimmed"> <Text size={compact ? "sm" : "md"} c="dimmed" ta="center">
{label} {label}
</Text> </Text>
</Stack> </Stack>
@@ -1,4 +1,4 @@
import { Group, SimpleGrid, Text } from "@mantine/core"; import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconWifi } from "@tabler/icons-react"; import { IconWifi } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -18,17 +18,19 @@ export const WifiVariant = ({
}) => { }) => {
const t = useI18n("widget.networkControllerStatus.card"); const t = useI18n("widget.networkControllerStatus.card");
return ( return (
<> <Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
<Group gap="xs" wrap="nowrap" mb={compact ? "xs" : "md"}> <Group gap="xs" wrap="nowrap" justify="center">
<IconWifi size={24} /> <Center w={24} h={24}>
<IconWifi size={20} />
</Center>
<Text size={"md"} fw={"bold"}> <Text size={"md"} fw={"bold"}>
{t("variants.wifi.name")} {t("variants.wifi.name")}
</Text> </Text>
</Group> </Group>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"}> <SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} /> <StatRow label={t("users.label")} value={countUsers} compact={compact} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} /> <StatRow label={t("guests.label")} value={countGuests} compact={compact} />
</SimpleGrid> </SimpleGrid>
</> </Stack>
); );
}; };
@@ -1,4 +1,4 @@
import { Group, SimpleGrid, Text } from "@mantine/core"; import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconNetwork } from "@tabler/icons-react"; import { IconNetwork } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -18,17 +18,19 @@ export const WiredVariant = ({
}) => { }) => {
const t = useI18n("widget.networkControllerStatus.card"); const t = useI18n("widget.networkControllerStatus.card");
return ( return (
<> <Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
<Group gap="xs" wrap="nowrap" mb={compact ? "xs" : "md"}> <Group gap="xs" wrap="nowrap" justify="center">
<IconNetwork size={24} /> <Center w={24} h={24}>
<IconNetwork size={20} />
</Center>
<Text size={"md"} fw={"bold"}> <Text size={"md"} fw={"bold"}>
{t("variants.wired.name")} {t("variants.wired.name")}
</Text> </Text>
</Group> </Group>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"}> <SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} /> <StatRow label={t("users.label")} value={countUsers} compact={compact} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} /> <StatRow label={t("guests.label")} value={countGuests} compact={compact} />
</SimpleGrid> </SimpleGrid>
</> </Stack>
); );
}; };
@@ -83,7 +83,13 @@ export default function NetworkControllerSummaryWidget({
{queryIndicators} {queryIndicators}
<SimpleGrid cols={controllerColumns} spacing="sm"> <SimpleGrid cols={controllerColumns} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => ( {summaries.map(({ integration, summary, updatedAt }) => (
<Card key={integration.id} p="sm" withBorder> <Card
key={integration.id}
p="sm"
withBorder
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
<Stack gap="sm"> <Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={0} style={{ minWidth: 0 }}> <Stack gap={0} style={{ minWidth: 0 }}>
@@ -105,7 +111,13 @@ export default function NetworkControllerSummaryWidget({
</Group> </Group>
<SimpleGrid cols={matrixColumns} spacing="xs"> <SimpleGrid cols={matrixColumns} spacing="xs">
{getNetworkControllerMatrix(summary).map((section) => ( {getNetworkControllerMatrix(summary).map((section) => (
<Card key={section.key} p="xs" withBorder> <Card
key={section.key}
p="xs"
withBorder
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
<Stack gap="xs"> <Stack gap="xs">
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
@@ -149,12 +161,18 @@ export default function NetworkControllerSummaryWidget({
} }
return ( return (
<ScrollArea h="100%" p={isDense ? "xs" : "sm"}> <Stack h="100%" p={isDense ? "xs" : "sm"} gap={0} style={{ overflow: "hidden" }}>
{queryIndicators} {queryIndicators}
<SimpleGrid cols={columns} spacing="sm"> <SimpleGrid cols={columns} spacing="sm" style={{ flex: 1, minHeight: 0, alignContent: "center" }}>
{summaries.map(({ integration, summary }) => ( {summaries.map(({ integration, summary }) => (
<Card key={integration.id} p={isDense ? "xs" : "sm"} withBorder={summaries.length > 1}> <Card
<Stack gap="xs"> key={integration.id}
p={isDense ? "xs" : "sm"}
withBorder={summaries.length > 1}
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
<Stack h="100%" gap={isDense ? 4 : 8} justify="center" align="center">
{summaries.length > 1 && ( {summaries.length > 1 && (
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Text fw={600} size="sm" truncate="end"> <Text fw={600} size="sm" truncate="end">
@@ -165,7 +183,7 @@ export default function NetworkControllerSummaryWidget({
</Badge> </Badge>
</Group> </Group>
)} )}
<List spacing={isDense ? 2 : "xs"} center size={isDense ? "sm" : undefined}> <List spacing={isDense ? 2 : 4} center size={isDense ? "xs" : "sm"} w="fit-content" mx="auto">
<List.Item <List.Item
icon={ icon={
<StatusIcon <StatusIcon
@@ -211,7 +229,7 @@ export default function NetworkControllerSummaryWidget({
<StatusIcon <StatusIcon
status={summary.lan.status} status={summary.lan.status}
label={statusLabels[getBinaryStatusKey(summary.lan.status)]} label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
size="var(--mantine-font-size-xl)" size={isDense ? 16 : 20}
/> />
} }
> >
@@ -239,7 +257,7 @@ export default function NetworkControllerSummaryWidget({
</Card> </Card>
))} ))}
</SimpleGrid> </SimpleGrid>
</ScrollArea> </Stack>
); );
} }
@@ -262,6 +280,9 @@ const getMatrixSectionLabel = (
return t(`card.${key}` as never); return t(`card.${key}` as never);
}; };
const neutralSurfaceBorder =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
const StatusIcon = ({ const StatusIcon = ({
status, status,
label, label,
+25 -3
View File
@@ -1,5 +1,6 @@
.tiptap hr { .tiptap hr {
border-top-style: double; border-top-style: double;
border-top-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
} }
.tiptap ul[data-type="taskList"] { .tiptap ul[data-type="taskList"] {
@@ -20,6 +21,10 @@
outline: 3px solid rgba(0, 65, 198, 0.8); outline: 3px solid rgba(0, 65, 198, 0.8);
} }
.tiptap table :where(tr) {
background-color: transparent;
}
.tiptap table { .tiptap table {
border-collapse: collapse; border-collapse: collapse;
margin: 0; margin: 0;
@@ -28,8 +33,8 @@
width: 100%; width: 100%;
} }
.tiptap table td { .tiptap table :where(th, td) {
border-color: var(--mantine-color-gray-5) !important; border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)) !important;
border-width: 1px !important; border-width: 1px !important;
border-style: solid !important; border-style: solid !important;
box-sizing: border-box; box-sizing: border-box;
@@ -39,7 +44,7 @@
vertical-align: top; vertical-align: top;
} }
.tiptap table td > * { .tiptap table :where(th, td) > * {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -66,6 +71,23 @@
.tiptap[contenteditable="false"].resize-cursor { .tiptap[contenteditable="false"].resize-cursor {
pointer-events: none; pointer-events: none;
} }
.homarr-notebook-toolbar-group {
background-color: rgb(from var(--mantine-color-body) r g b / calc(var(--opacity, 1) * 0.12));
}
.homarr-notebook .tiptap :where(pre, blockquote) {
background-color: rgb(from var(--mantine-color-body) r g b / calc(var(--opacity, 1) * 0.12));
}
.homarr-notebook .tiptap :where(code, kbd) {
background-color: rgb(from var(--mantine-color-body) r g b / calc(var(--opacity, 1) * 0.12));
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
}
.homarr-notebook .tiptap :where(pre) :where(code) {
background-color: transparent;
}
.ProseMirror { .ProseMirror {
height: 100%; height: 100%;
+12 -12
View File
@@ -121,8 +121,6 @@ export function Notebook({
}, [options.allowReadOnlyCheck]); }, [options.allowReadOnlyCheck]);
const { primaryColor } = useMantineTheme(); const { primaryColor } = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const { mutateAsync, isPending: isSaving } = clientApi.widget.notebook.updateContent.useMutation({ const { mutateAsync, isPending: isSaving } = clientApi.widget.notebook.updateContent.useMutation({
scope: { id: `notebook-content:${boardId ?? "preview"}:${itemId ?? "preview"}` }, scope: { id: `notebook-content:${boardId ?? "preview"}:${itemId ?? "preview"}` },
}); });
@@ -360,9 +358,9 @@ export function Notebook({
isEditing && !isSaving ? getHotkeyHandler([[hotkeys.saveNotebook, () => void handleEditToggle()]]) : undefined isEditing && !isSaving ? getHotkeyHandler([[hotkeys.saveNotebook, () => void handleEditToggle()]]) : undefined
} }
editor={editor} editor={editor}
styles={(theme) => ({ styles={() => ({
root: { root: {
backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : "white", backgroundColor: "transparent",
border: "none", border: "none",
borderRadius: "0.5rem", borderRadius: "0.5rem",
display: "flex", display: "flex",
@@ -371,6 +369,8 @@ export function Notebook({
}, },
toolbar: { toolbar: {
backgroundColor: "transparent", backgroundColor: "transparent",
borderColor:
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))",
padding: "0.5rem", padding: "0.5rem",
}, },
content: { content: {
@@ -390,7 +390,7 @@ export function Notebook({
overflowY: display.toolbarMaxHeight === undefined ? undefined : "auto", overflowY: display.toolbarMaxHeight === undefined ? undefined : "auto",
}} }}
> >
<RichTextEditor.ControlsGroup> <RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.Bold title={tControls("bold")} /> <RichTextEditor.Bold title={tControls("bold")} />
<RichTextEditor.Italic title={tControls("italic")} /> <RichTextEditor.Italic title={tControls("italic")} />
<RichTextEditor.Strikethrough title={tControls("strikethrough")} /> <RichTextEditor.Strikethrough title={tControls("strikethrough")} />
@@ -401,14 +401,14 @@ export function Notebook({
<RichTextEditor.ClearFormatting title={tControls("clear")} /> <RichTextEditor.ClearFormatting title={tControls("clear")} />
</RichTextEditor.ControlsGroup> </RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup> <RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.H1 title={tControls("heading", { level: "1" })} /> <RichTextEditor.H1 title={tControls("heading", { level: "1" })} />
<RichTextEditor.H2 title={tControls("heading", { level: "2" })} /> <RichTextEditor.H2 title={tControls("heading", { level: "2" })} />
<RichTextEditor.H3 title={tControls("heading", { level: "3" })} /> <RichTextEditor.H3 title={tControls("heading", { level: "3" })} />
<RichTextEditor.H4 title={tControls("heading", { level: "4" })} /> <RichTextEditor.H4 title={tControls("heading", { level: "4" })} />
</RichTextEditor.ControlsGroup> </RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup> <RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.AlignLeft <RichTextEditor.AlignLeft
title={tControls("align", { title={tControls("align", {
position: t("align.left"), position: t("align.left"),
@@ -442,12 +442,12 @@ export function Notebook({
</RichTextEditor.Control> </RichTextEditor.Control>
</RichTextEditor.ControlsGroup> </RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup> <RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.Blockquote title={tControls("blockquote")} /> <RichTextEditor.Blockquote title={tControls("blockquote")} />
<RichTextEditor.Hr title={tControls("horizontalLine")} /> <RichTextEditor.Hr title={tControls("horizontalLine")} />
</RichTextEditor.ControlsGroup> </RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup> <RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.BulletList title={tControls("bulletList")} /> <RichTextEditor.BulletList title={tControls("bulletList")} />
<RichTextEditor.OrderedList title={tControls("orderedList")} /> <RichTextEditor.OrderedList title={tControls("orderedList")} />
<TaskListToggle /> <TaskListToggle />
@@ -461,13 +461,13 @@ export function Notebook({
)} )}
</RichTextEditor.ControlsGroup> </RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup> <RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.Link title={tControls("link")} /> <RichTextEditor.Link title={tControls("link")} />
<RichTextEditor.Unlink title={tControls("unlink")} /> <RichTextEditor.Unlink title={tControls("unlink")} />
<EmbedImage /> <EmbedImage />
</RichTextEditor.ControlsGroup> </RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup> <RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<TableToggle /> <TableToggle />
{editor?.isActive("table") && ( {editor?.isActive("table") && (
<> <>
@@ -483,7 +483,7 @@ export function Notebook({
)} )}
</RichTextEditor.ControlsGroup> </RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup> <RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.Undo /> <RichTextEditor.Undo />
<RichTextEditor.Redo /> <RichTextEditor.Redo />
</RichTextEditor.ControlsGroup> </RichTextEditor.ControlsGroup>
@@ -0,0 +1,10 @@
.card,
.row {
background-color: transparent;
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.38));
}
.row {
border-bottom-style: solid;
border-bottom-width: 1px;
}
@@ -14,6 +14,7 @@ import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { getNotificationDisplay } from "./display"; import { getNotificationDisplay } from "./display";
import classes from "./component.module.css";
export default function NotificationsWidget({ export default function NotificationsWidget({
options, options,
@@ -97,17 +98,13 @@ export default function NotificationsWidget({
href={href} href={href}
target={href ? "_blank" : undefined} target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined} rel={href ? SAFE_NEW_TAB_REL : undefined}
className={columns > 1 ? classes.card : classes.row}
radius={board.itemRadius} radius={board.itemRadius}
w="100%" w="100%"
p={isRoomy ? "sm" : isDense ? 6 : "xs"} p={isRoomy ? "sm" : isDense ? 6 : "xs"}
bg={columns > 1 ? undefined : "transparent"}
style={{ style={{
color: "inherit", color: "inherit",
textDecoration: "none", textDecoration: "none",
borderBottom:
columns > 1
? undefined
: "1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4))",
}} }}
> >
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%"> <Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
@@ -16,7 +16,8 @@
gap: clamp(8px, 3cqi, 18px); gap: clamp(8px, 3cqi, 18px);
flex-shrink: 0; flex-shrink: 0;
padding-bottom: clamp(4px, 2cqi, 10px); padding-bottom: clamp(4px, 2cqi, 10px);
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); border-bottom: 1px solid
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
} }
.heroExpanded { .heroExpanded {
@@ -97,7 +98,9 @@
gap: clamp(2px, 1.5cqi, 5px); gap: clamp(2px, 1.5cqi, 5px);
padding: clamp(4px, 2.5cqi, 10px); padding: clamp(4px, 2.5cqi, 10px);
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); border: 1px solid
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent;
overflow: hidden; overflow: hidden;
} }
@@ -120,7 +123,7 @@
.statValue { .statValue {
font-size: clamp(14px, 5.5cqi, 24px); font-size: clamp(14px, 5.5cqi, 24px);
font-weight: 700; font-weight: 700;
color: var(--mantine-primary-color-filled); color: var(--mantine-color-text);
line-height: 1.1; line-height: 1.1;
text-align: center; text-align: center;
} }
@@ -2,11 +2,16 @@
min-width: 0; min-width: 0;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
transition: background-color 120ms ease; background-color: transparent;
border: 1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
transition:
background-color 120ms ease,
border-color 120ms ease;
&:hover, &:hover,
&:focus-visible { &:focus-visible {
background-color: var(--mantine-color-default-hover); background-color: transparent;
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.62));
} }
&:focus-visible { &:focus-visible {
@@ -24,3 +29,9 @@
transition: none; transition: none;
} }
} }
@media (forced-colors: active) {
.entry {
border-color: CanvasText;
}
}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Box, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core"; import { Card, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core";
import { useElementSize } from "@mantine/hooks"; import { useElementSize } from "@mantine/hooks";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
@@ -8,7 +8,7 @@ import type { TablerIcon } from "@homarr/ui";
export interface SpeedStatCardProps { export interface SpeedStatCardProps {
icon: TablerIcon; icon: TablerIcon;
color: string; color: "blue" | "teal" | "orange" | "green" | "red";
value: string; value: string;
label: string; label: string;
compact?: boolean; compact?: boolean;
@@ -19,15 +19,22 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
const board = useRequiredBoard(); const board = useRequiredBoard();
const isWide = width > height + 20; const isWide = width > height + 20;
const hideLabel = height > 0 && height <= 38; const hideLabel = height > 0 && height <= 38;
const surfaceColor = `var(--mantine-color-${color}-filled)`;
const accentColor = `var(--mantine-color-${color}-5)`;
const surfaceAlpha = compact ? 0.1 : 0.14;
const surfaceBackground = `rgb(from ${surfaceColor} r g b / calc(var(--opacity, 1) * ${surfaceAlpha}))`;
const surfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
return ( return (
<Tooltip label={`${label}: ${value}`} withArrow> <Tooltip label={`${label}: ${value}`} withArrow>
<Box <Card
ref={ref} ref={ref}
p={compact ? "xs" : "sm"} p={compact ? "xs" : "sm"}
bg={`var(--mantine-color-${color}-light)`} radius={board.itemRadius}
withBorder
bg={surfaceBackground}
h="100%" h="100%"
style={{ flex: 1, borderRadius: `var(--mantine-radius-${board.itemRadius})`, minWidth: 0 }} style={{ flex: 1, minWidth: 0, borderColor: surfaceBorder }}
> >
<Flex <Flex
h="100%" h="100%"
@@ -38,7 +45,7 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
gap={isWide ? 8 : 4} gap={isWide ? 8 : 4}
style={{ minWidth: 0 }} style={{ minWidth: 0 }}
> >
<Icon size={compact ? 16 : 20} color={`var(--mantine-color-${color}-5)`} style={{ flexShrink: 0 }} /> <Icon size={compact ? 16 : 20} color={accentColor} style={{ flexShrink: 0 }} />
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0} style={{ minWidth: 0 }}> <Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0} style={{ minWidth: 0 }}>
<Text size={compact ? "sm" : "md"} fw={700} ta="center" lh={1.1} truncate w="100%"> <Text size={compact ? "sm" : "md"} fw={700} ta="center" lh={1.1} truncate w="100%">
{value} {value}
@@ -51,7 +58,7 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
{hideLabel && <VisuallyHidden>{label}</VisuallyHidden>} {hideLabel && <VisuallyHidden>{label}</VisuallyHidden>}
</Flex> </Flex>
</Flex> </Flex>
</Box> </Card>
</Tooltip> </Tooltip>
); );
} }
+59 -30
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip, useMantineColorScheme } from "@mantine/core"; import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
@@ -18,6 +18,34 @@ import { NoIntegrationDataError } from "../errors/no-data-integration";
type DiskDisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"]; type DiskDisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
const advancedColumnBreakpoints = [
{ minimumWidth: 1820, columns: 7 },
{ minimumWidth: 1560, columns: 6 },
{ minimumWidth: 1300, columns: 5 },
{ minimumWidth: 1040, columns: 4 },
{ minimumWidth: 780, columns: 3 },
{ minimumWidth: 520, columns: 2 },
{ minimumWidth: 0, columns: 1 },
] as const;
const getSystemDisksLayout = (width: number, height: number, diskCount: number, isAdvanced: boolean) => {
if (!isAdvanced) {
return {
columns: 1,
showSecondaryText: height >= 160 && width >= 260,
showTemperature: height >= 100 && width >= 220,
};
}
const breakpoint = advancedColumnBreakpoints.find((candidate) => width >= candidate.minimumWidth);
const columns = Math.min(diskCount, breakpoint?.columns ?? 1);
return {
columns: Math.max(1, columns),
showSecondaryText: true,
showTemperature: true,
};
};
export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage)); export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage));
export const getDisplayText = ( export const getDisplayText = (
@@ -85,7 +113,6 @@ const SystemDiskCard = ({
showTemperature, showTemperature,
}: SystemDiskCardProps) => { }: SystemDiskCardProps) => {
const board = useRequiredBoard(); const board = useRequiredBoard();
const scheme = useMantineColorScheme();
const t = useI18n("widget.systemDisks"); const t = useI18n("widget.systemDisks");
const valueRef = useRef<HTMLParagraphElement>(null); const valueRef = useRef<HTMLParagraphElement>(null);
const [valueFits, setValueFits] = useState(true); const [valueFits, setValueFits] = useState(true);
@@ -120,6 +147,8 @@ const SystemDiskCard = ({
] ]
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
return ( return (
<Tooltip <Tooltip
@@ -131,8 +160,9 @@ const SystemDiskCard = ({
<Card <Card
radius={board.itemRadius} radius={board.itemRadius}
py="xs" py="xs"
bg={scheme.colorScheme === "dark" ? "dark.7" : "gray.1"} withBorder
style={{ overflow: "hidden", position: "relative" }} bg={backgroundColor}
style={{ overflow: "hidden", position: "relative", borderColor }}
> >
<Group justify="space-between" wrap="nowrap" style={{ zIndex: 1, minWidth: 0 }}> <Group justify="space-between" wrap="nowrap" style={{ zIndex: 1, minWidth: 0 }}>
<div style={{ minWidth: 0, flex: 1 }}> <div style={{ minWidth: 0, flex: 1 }}>
@@ -199,6 +229,27 @@ export default function SystemResources({
const data = results.filter( const data = results.filter(
(entry): entry is typeof entry & { healthInfo: NonNullable<typeof entry.healthInfo> } => entry.healthInfo !== null, (entry): entry is typeof entry & { healthInfo: NonNullable<typeof entry.healthInfo> } => entry.healthInfo !== null,
); );
const disks = data.flatMap((entry) => {
const fileSystem = filterStorageVolumes(
entry.healthInfo.fileSystem,
options.visibleStorageVolumes,
entry.integrationId,
);
const smart = filterStorageVolumes(entry.healthInfo.smart, options.visibleStorageVolumes, entry.integrationId);
return fileSystem.map((item) => ({
integrationId: entry.integrationId,
integrationName: entry.integrationName,
item,
smartItem: smart.find(
(candidate) => normalizeStorageDeviceName(candidate.deviceName) === normalizeStorageDeviceName(item.deviceName),
),
}));
});
const isAdvanced = displayMode === "advanced";
const layout = useMemo(
() => getSystemDisksLayout(width, height, disks.length, isAdvanced),
[disks.length, height, isAdvanced, width],
);
const queryIndicators = ( const queryIndicators = (
<Group gap={0}> <Group gap={0}>
<IntegrationErrorIndicator results={results} /> <IntegrationErrorIndicator results={results} />
@@ -217,40 +268,18 @@ export default function SystemResources({
if (isInitialWidgetQueryPending(healthQuery)) return <WidgetQueryLoadingState />; if (isInitialWidgetQueryPending(healthQuery)) return <WidgetQueryLoadingState />;
if (data.length === 0) return emptyState; if (data.length === 0) return emptyState;
const disks = data.flatMap((entry) => {
const fileSystem = filterStorageVolumes(
entry.healthInfo.fileSystem,
options.visibleStorageVolumes,
entry.integrationId,
);
const smart = filterStorageVolumes(entry.healthInfo.smart, options.visibleStorageVolumes, entry.integrationId);
return fileSystem.map((item) => ({
integrationId: entry.integrationId,
integrationName: entry.integrationName,
item,
smartItem: smart.find(
(candidate) => normalizeStorageDeviceName(candidate.deviceName) === normalizeStorageDeviceName(item.deviceName),
),
}));
});
const hasNoFileSystems = data.every((entry) => entry.healthInfo.fileSystem.length === 0); const hasNoFileSystems = data.every((entry) => entry.healthInfo.fileSystem.length === 0);
const hasFailedSource = results.some((entry) => Boolean(entry.error)); const hasFailedSource = results.some((entry) => Boolean(entry.error));
if (hasNoFileSystems && !hasFailedSource && !healthQuery.error) throw new NoIntegrationDataError(); if (hasNoFileSystems && !hasFailedSource && !healthQuery.error) throw new NoIntegrationDataError();
if (hasNoFileSystems || disks.length === 0) return emptyState; if (hasNoFileSystems || disks.length === 0) return emptyState;
const isAdvanced = displayMode === "advanced";
const minimumCardWidth = 260;
const columns = Math.max(1, Math.min(disks.length, Math.floor(width / minimumCardWidth)));
const cellWidth = width / columns;
return ( return (
<Box h="100%" pos="relative"> <Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}> <Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators} {queryIndicators}
</Box> </Box>
<ScrollArea h="100%"> <ScrollArea h="100%">
<SimpleGrid cols={columns} spacing="xs" p="xs"> <SimpleGrid cols={layout.columns} spacing="xs" p="xs">
{disks.map(({ integrationId, integrationName, item, smartItem }) => { {disks.map(({ integrationId, integrationName, item, smartItem }) => {
const freeText = t("status.free", { const freeText = t("status.free", {
percentage: String(Math.round(100 - clampPercentage(item.percentage))), percentage: String(Math.round(100 - clampPercentage(item.percentage))),
@@ -273,8 +302,8 @@ export default function SystemResources({
freeText={advancedDisplayTexts.free} freeText={advancedDisplayTexts.free}
smartStatus={smartItem?.overallStatus} smartStatus={smartItem?.overallStatus}
isAdvanced={isAdvanced} isAdvanced={isAdvanced}
showSecondaryText={isAdvanced || (height >= 160 && cellWidth >= 260)} showSecondaryText={layout.showSecondaryText}
showTemperature={isAdvanced || (height >= 100 && cellWidth >= 220)} showTemperature={layout.showTemperature}
/> />
); );
})} })}
@@ -2,7 +2,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { AreaChartSeries } from "@mantine/charts"; import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts"; import { AreaChart, LineChart } from "@mantine/charts";
import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core"; import { Card, Center, Group, Stack, Text, useMantineTheme } from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks"; import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { TooltipProps, YAxisProps } from "recharts"; import type { TooltipProps, YAxisProps } from "recharts";
@@ -36,13 +36,12 @@ export const CommonChart = ({
}) => { }) => {
const { ref: elementSizeRef, height } = useElementSize(); const { ref: elementSizeRef, height } = useElementSize();
const theme = useMantineTheme(); const theme = useMantineTheme();
const colorScheme = useComputedColorScheme("light");
const board = useRequiredBoard(); const board = useRequiredBoard();
const { hovered, ref: hoverRef } = useHover(); const { hovered, ref: hoverRef } = useHover();
const ref = useMergedRef(elementSizeRef, hoverRef); const ref = useMergedRef(elementSizeRef, hoverRef);
const opacity = board.opacity / 100; const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
const backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`; const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
const ChartComponent = chartType === "line" ? LineChart : AreaChart; const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon"; const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
@@ -53,7 +52,7 @@ export const CommonChart = ({
ref={ref} ref={ref}
h={"100%"} h={"100%"}
pos={"relative"} pos={"relative"}
style={{ overflow: "visible" }} style={{ overflow: "visible", border: `1px solid ${borderColor}` }}
p={0} p={0}
bg={backgroundColor} bg={backgroundColor}
radius={board.itemRadius} radius={board.itemRadius}
@@ -119,7 +118,13 @@ export const CommonChart = ({
withYAxis={false} withYAxis={false}
withDots={false} withDots={false}
bg={backgroundColor} bg={backgroundColor}
styles={{ root: { padding: 5, borderRadius: theme.radius[board.itemRadius] } }} styles={{
root: {
padding: 5,
borderRadius: theme.radius[board.itemRadius],
backgroundColor,
},
}}
tooltipAnimationDuration={200} tooltipAnimationDuration={200}
tooltipProps={tooltipProps} tooltipProps={tooltipProps}
withTooltip={height >= 64} withTooltip={height >= 64}
@@ -230,18 +230,19 @@ const SystemCharts = ({
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode; const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
const chartCount = visibleCharts.length; const chartCount = visibleCharts.length;
const chartColumns = isAdvanced && width >= 560 ? 2 : 1; const chartColumns = isAdvanced && width >= 560 ? 2 : 1;
const compactChartGap = 8;
const chartHeight = isAdvanced const chartHeight = isAdvanced
? 180 ? 180
: `calc((100% - ${Math.max(0, chartCount - 1) * 10}px) / ${Math.max(1, chartCount)})`; : `calc((100% - ${Math.max(0, chartCount - 1) * compactChartGap}px) / ${Math.max(1, chartCount)})`;
return ( return (
<Stack gap="xs" h={isAdvanced ? "auto" : height} miw={0}> <Stack gap={isAdvanced ? "xs" : compactChartGap} h={isAdvanced ? "auto" : height} miw={0}>
{showTitle && ( {showTitle && (
<Text size="sm" fw={600} truncate="end"> <Text size="sm" fw={600} truncate="end">
{integrationName} {integrationName}
</Text> </Text>
)} )}
<SimpleGrid cols={chartColumns} spacing="xs" style={{ flex: 1, minHeight: 0 }}> <SimpleGrid cols={chartColumns} spacing={isAdvanced ? "xs" : compactChartGap} style={{ flex: 1, minHeight: 0 }}>
{chartCount === 0 && ( {chartCount === 0 && (
<Center h="100%"> <Center h="100%">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -35,7 +35,7 @@ export function RecentActivityList({
: session.mediaTitle; : session.mediaTitle;
return ( return (
<Paper key={session.key} p="sm" radius="lg"> <Paper key={session.key} p="sm" radius="lg" bg="transparent">
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}> <Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
<Avatar src={session.user.avatarUrl} alt={session.user.username} radius="xl" size="sm" /> <Avatar src={session.user.avatarUrl} alt={session.user.username} radius="xl" size="sm" />
@@ -44,7 +44,7 @@ export function StatsBar({
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | number }) { function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | number }) {
return ( return (
<Paper p="xs" radius="lg"> <Paper p="xs" radius="lg" bg="transparent">
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
{icon} {icon}
<Stack gap={0} style={{ overflow: "hidden" }}> <Stack gap={0} style={{ overflow: "hidden" }}>
@@ -65,7 +65,7 @@ function StreamCard({
: null; : null;
return ( return (
<Paper p="xs" radius="lg" style={{ position: "relative", overflow: "hidden" }}> <Paper p="xs" radius="lg" bg="transparent" style={{ position: "relative", overflow: "hidden" }}>
{stream.posterUrl && ( {stream.posterUrl && (
<Box <Box
style={{ style={{
@@ -30,7 +30,7 @@ export function ViolationsList({
) : ( ) : (
<Stack gap="xs"> <Stack gap="xs">
{violations.map((violation) => ( {violations.map((violation) => (
<Paper key={violation.key} p="xs" radius="lg"> <Paper key={violation.key} p="xs" radius="lg" bg="transparent">
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}> <Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
<IconAlertTriangle <IconAlertTriangle
@@ -39,7 +39,7 @@
gap: clamp(5px, 2cqi, 8px); gap: clamp(5px, 2cqi, 8px);
padding: clamp(6px, 3cqi, 10px); padding: clamp(6px, 3cqi, 10px);
border-radius: var(--mantine-radius-sm); border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); background-color: transparent;
} }
.metricIcon { .metricIcon {
@@ -75,7 +75,7 @@
.protocolCard { .protocolCard {
min-width: 0; min-width: 0;
padding: clamp(6px, 3cqi, 10px); padding: clamp(6px, 3cqi, 10px);
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); border: 1px solid rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
border-radius: var(--mantine-radius-sm); border-radius: var(--mantine-radius-sm);
} }
@@ -0,0 +1,21 @@
.chartSurface {
min-width: 0;
min-height: 0;
background-color: transparent !important;
}
.chartSurface :global(.mantine-ScrollArea-viewport),
.chartSurface :global(.mantine-ScrollArea-scrollbar),
.chartSurface :global(.recharts-wrapper),
.chartSurface :global(.recharts-surface) {
background-color: transparent !important;
}
.listSurface {
min-width: 0;
min-height: 0;
padding: 4px 6px;
border: 1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.32));
border-radius: var(--mantine-radius-sm);
background-color: transparent;
}
+9 -6
View File
@@ -7,6 +7,7 @@ import { clientApi } from "@homarr/api/client";
import { formatDuration } from "@homarr/common"; import { formatDuration } from "@homarr/common";
import type { UmamiVisitorStats } from "@homarr/integrations/types"; import type { UmamiVisitorStats } from "@homarr/integrations/types";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import { UmamiEventsContent } from "./umami-events-content"; import { UmamiEventsContent } from "./umami-events-content";
import { getUmamiLayout } from "./umami-layout"; import { getUmamiLayout } from "./umami-layout";
@@ -161,7 +162,7 @@ export function UmamiContent({
); );
const advancedLists = ( const advancedLists = (
<SimpleGrid cols={viewMode === "topPages" || viewMode === "topReferrers" ? 1 : 2} spacing="md" mih={260}> <SimpleGrid cols={viewMode === "topPages" || viewMode === "topReferrers" ? 1 : 2} spacing="xs" mih={180}>
{viewMode !== "topPages" && ( {viewMode !== "topPages" && (
<UmamiTopPagesContent <UmamiTopPagesContent
integrationIds={integrationIds} integrationIds={integrationIds}
@@ -182,7 +183,7 @@ export function UmamiContent({
); );
const advancedContent = stackAdvancedContent ? ( const advancedContent = stackAdvancedContent ? (
<ScrollArea h="100%"> <ScrollArea className={classes.chartSurface} h="100%">
<Stack gap="md"> <Stack gap="md">
<Box h={Math.max(280, Math.min(420, height - 160))}>{selectedView}</Box> <Box h={Math.max(280, Math.min(420, height - 160))}>{selectedView}</Box>
{advancedLists} {advancedLists}
@@ -190,15 +191,17 @@ export function UmamiContent({
</ScrollArea> </ScrollArea>
) : ( ) : (
<SimpleGrid cols={2} spacing="md" h="100%" style={{ gridTemplateRows: "minmax(0, 1fr)" }}> <SimpleGrid cols={2} spacing="md" h="100%" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
<Box h="100%" mih={260}> <Box className={classes.chartSurface} h="100%" mih={260}>
{selectedView} {selectedView}
</Box> </Box>
<ScrollArea h="100%">{advancedLists}</ScrollArea> <ScrollArea className={classes.chartSurface} h="100%">
{advancedLists}
</ScrollArea>
</SimpleGrid> </SimpleGrid>
); );
return ( return (
<Stack gap={isDense ? 2 : 4} p={isDense ? 4 : "xs"} h="100%" pos="relative"> <Stack gap={isDense ? 1 : 4} p={isDense ? 2 : "xs"} h="100%" pos="relative">
<Group justify="space-between" align="baseline" wrap="nowrap"> <Group justify="space-between" align="baseline" wrap="nowrap">
<Text size="xs" c="dimmed" truncate="end" style={{ maxWidth: "55%" }}> <Text size="xs" c="dimmed" truncate="end" style={{ maxWidth: "55%" }}>
{visitorStats.domain} ({formatTimeFrameLabel(timeFrame, t)}) {visitorStats.domain} ({formatTimeFrameLabel(timeFrame, t)})
@@ -264,7 +267,7 @@ export function UmamiContent({
)} )}
</Group> </Group>
)} )}
<Box mt={4} style={{ flex: 1, minHeight: 0 }}> <Box className={classes.chartSurface} mt={4} style={{ flex: 1, minHeight: 0 }}>
{displayMode === "advanced" ? advancedContent : selectedView} {displayMode === "advanced" ? advancedContent : selectedView}
</Box> </Box>
{(statsQuery.error || activeVisitorsQuery.error || multiEventQuery.error) && ( {(statsQuery.error || activeVisitorsQuery.error || multiEventQuery.error) && (
@@ -5,6 +5,7 @@ import { Group, ScrollArea, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import type { UmamiMetricItem } from "@homarr/integrations/types"; import type { UmamiMetricItem } from "@homarr/integrations/types";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import { umamiQueryOptions } from "./umami-utils"; import { umamiQueryOptions } from "./umami-utils";
import { getUsableWidgetQueryData } from "../common/query-state"; import { getUsableWidgetQueryData } from "../common/query-state";
@@ -52,7 +53,7 @@ function UmamiTopList({
const locale = useCurrentIntlLocale(); const locale = useCurrentIntlLocale();
return ( return (
<Stack gap={2} h="100%"> <Stack className={classes.listSurface} gap={2} h="100%">
<Text size="xs" c="dimmed" fw={500}> <Text size="xs" c="dimmed" fw={500}>
{heading} {heading}
</Text> </Text>
+17 -4
View File
@@ -35,6 +35,11 @@ const statusColors: Record<UpsStatus, string> = {
unknown: "gray", unknown: "gray",
}; };
const neutralSurfaceBackground =
"rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.12))";
const neutralSurfaceBorder =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
type UpsLayout = "mini" | "compact" | "full"; type UpsLayout = "mini" | "compact" | "full";
export default function UpsWidget({ export default function UpsWidget({
@@ -183,7 +188,7 @@ function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: Up
if (layout === "mini") { if (layout === "mini") {
return ( return (
<Card p={6} radius="md"> <Card p={6} radius="md" bg="transparent">
<Group gap={4} wrap="nowrap" justify="center" mb={showRing ? 4 : 0}> <Group gap={4} wrap="nowrap" justify="center" mb={showRing ? 4 : 0}>
<Box <Box
w={8} w={8}
@@ -211,6 +216,7 @@ function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: Up
size={44} size={44}
thickness={4} thickness={4}
roundCaps roundCaps
rootColor={neutralSurfaceBorder}
sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]} sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]}
label={ label={
<Text ta="center" size="10px" fw={700}> <Text ta="center" size="10px" fw={700}>
@@ -226,13 +232,14 @@ function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: Up
if (layout === "compact") { if (layout === "compact") {
return ( return (
<Card p={6} radius="md"> <Card p={6} radius="md" bg="transparent">
<Group gap={8} wrap="nowrap" align="center"> <Group gap={8} wrap="nowrap" align="center">
{showRing && summary.batteryCharge !== null && ( {showRing && summary.batteryCharge !== null && (
<RingProgress <RingProgress
size={40} size={40}
thickness={4} thickness={4}
roundCaps roundCaps
rootColor={neutralSurfaceBorder}
sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]} sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]}
label={ label={
<Text ta="center" size="9px" fw={700}> <Text ta="center" size="9px" fw={700}>
@@ -258,7 +265,7 @@ function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: Up
} }
return ( return (
<Card p="xs" radius="md"> <Card p="xs" radius="md" bg="transparent">
<Group justify="space-between" wrap="nowrap" mb={4}> <Group justify="space-between" wrap="nowrap" mb={4}>
<Stack gap={0} style={{ minWidth: 0 }}> <Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate> <Text fw={600} size="sm" truncate>
@@ -279,6 +286,7 @@ function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: Up
size={68} size={68}
thickness={6} thickness={6}
roundCaps roundCaps
rootColor={neutralSurfaceBorder}
sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]} sections={[{ value: clampPercent(summary.batteryCharge), color: getChargeColor(summary.batteryCharge) }]}
label={ label={
<Text ta="center" size="xs" fw={700}> <Text ta="center" size="xs" fw={700}>
@@ -301,7 +309,12 @@ function UpsDeviceCard({ summary, sourceName, options, layout, advanced, t }: Up
</Text> </Text>
<Text size="xs">{Math.round(summary.load)}%</Text> <Text size="xs">{Math.round(summary.load)}%</Text>
</Group> </Group>
<Progress value={clampPercent(summary.load)} size="sm" color={getLoadColor(summary.load)} /> <Progress
value={clampPercent(summary.load)}
size="sm"
color={getLoadColor(summary.load)}
styles={{ root: { backgroundColor: neutralSurfaceBackground } }}
/>
</Box> </Box>
)} )}
@@ -16,7 +16,8 @@
gap: clamp(8px, 3cqi, 18px); gap: clamp(8px, 3cqi, 18px);
flex-shrink: 0; flex-shrink: 0;
padding-bottom: clamp(4px, 2cqi, 10px); padding-bottom: clamp(4px, 2cqi, 10px);
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); border-bottom: 1px solid
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
} }
.heroExpanded { .heroExpanded {
@@ -83,7 +84,9 @@
gap: clamp(2px, 1.5cqi, 5px); gap: clamp(2px, 1.5cqi, 5px);
padding: clamp(4px, 2.5cqi, 10px); padding: clamp(4px, 2.5cqi, 10px);
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); border: 1px solid
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent;
overflow: hidden; overflow: hidden;
} }
+11 -1
View File
@@ -19,6 +19,9 @@ import { getStatusColor, RUNNING_STATUS } from "./helpers";
export type VpnInfo = RouterOutputs["widget"]["vpn"]["getSummaries"][number]["summary"]; export type VpnInfo = RouterOutputs["widget"]["vpn"]["getSummaries"][number]["summary"];
const neutralBorderColor =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
export function VpnIntegrationCard({ export function VpnIntegrationCard({
vpn, vpn,
integrationName, integrationName,
@@ -53,7 +56,14 @@ export function VpnIntegrationCard({
if (compact) { if (compact) {
return ( return (
<Card withBorder radius={board.itemRadius} p={dense ? 6 : "xs"} w="100%"> <Card
withBorder
radius={board.itemRadius}
p={dense ? 6 : "xs"}
w="100%"
bg="transparent"
style={{ borderColor: neutralBorderColor }}
>
{content} {content}
</Card> </Card>
); );
@@ -31,8 +31,8 @@
.dailyCard, .dailyCard,
.detailMetric, .detailMetric,
.solarDetails { .solarDetails {
background: color-mix(in srgb, var(--mantine-color-body) 72%, transparent); background-color: transparent;
border: 0.0625rem solid color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent); border: 0.0625rem solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45));
} }
.dailyCard { .dailyCard {
@@ -43,8 +43,8 @@
.dailyCardButton:hover .dailyCard, .dailyCardButton:hover .dailyCard,
.dailyCardButton:focus-visible .dailyCard { .dailyCardButton:focus-visible .dailyCard {
background: color-mix(in srgb, var(--mantine-primary-color-light) 30%, var(--mantine-color-body)); background-color: transparent;
border-color: color-mix(in srgb, var(--mantine-primary-color-filled) 45%, var(--mantine-color-default-border)); border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.72));
} }
.dailyDetailsDropdown { .dailyDetailsDropdown {
@@ -74,7 +74,7 @@
} }
.solarDetails { .solarDetails {
background: color-mix(in srgb, var(--mantine-color-yellow-light) 24%, var(--mantine-color-body)); background-color: transparent;
} }
.detailsButton, .detailsButton,
@@ -1,7 +1,3 @@
[data-mantine-color-scheme="light"] .card { .card {
background-color: var(--mantine-color-gray-1); background-color: transparent;
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
} }
+8 -2
View File
@@ -206,14 +206,20 @@ const UpdateCard = ({
? t("updateAvailable") ? t("updateAvailable")
: buildVersionText(truncateVersion(update.currentVersion), truncateVersion(update.newVersion)); : buildVersionText(truncateVersion(update.currentVersion), truncateVersion(update.newVersion));
const versionBadge = versionText ? ( const versionBadge = versionText ? (
<Badge size="xs" variant="light" color="gray" style={{ whiteSpace: "nowrap" }}> <Badge size="xs" variant="subtle" color="gray" style={{ whiteSpace: "nowrap" }}>
{versionText} {versionText}
</Badge> </Badge>
) : null; ) : null;
const showVersionTooltip = versionText !== null && !isDigestUpdate && versionText !== fullVersionText; const showVersionTooltip = versionText !== null && !isDigestUpdate && versionText !== fullVersionText;
return ( return (
<Card className={combineClasses(className)} radius={radius} p="xs" style={{ overflow: "visible" }}> <Card
className={combineClasses(className)}
radius={radius}
p="xs"
bg="transparent"
style={{ overflow: "visible" }}
>
<Group justify="space-between" wrap="nowrap" gap="xs" miw={0}> <Group justify="space-between" wrap="nowrap" gap="xs" miw={0}>
<Text size="xs" fw={500} lineClamp={1} miw={0}> <Text size="xs" fw={500} lineClamp={1} miw={0}>
{update.name} {update.name}