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