fix: restore translucent dashboard styling (#6664)

* fix: restore translucent dashboard styling

* fix: restore dashboard widget transparency

* fix: address dashboard review feedback

* fix: apply board opacity to containers

* fix: flatten demo board container

* fix: default board roundness to sm

* fix: restore assistant widget transparency

* Revert "fix: default board roundness to sm"

This reverts commit c235cbdf2b.

* fix: address remaining dashboard review comments

* test: align grid geometry expectations
This commit is contained in:
Thomas authored and GitHub committed 2026-08-22 15:51:43 +02:00
1 parent 2570de25a6
commit 19760e5722
88 files changed
+940 -620

No files matched your search

@@ -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 => {
+1 -1
View File
@@ -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%;
+30 -15
View File
@@ -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));
}
+10 -8
View File
@@ -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
+6 -2
View File
@@ -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"}
+5
View File
@@ -0,0 +1,5 @@
/* Keep Docker rows translucent so board wallpaper and the inherited item opacity remain visible. */
.homarr-data-table.docker-table {
--mantine-datatable-background-color: transparent !important;
--mantine-datatable-striped-color: transparent !important;
}
+2 -8
View File
@@ -143,11 +143,6 @@ const stateColorMap: Record<DownloadState, string> = {
unknown: "gray",
};
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>
+3 -1
View File
@@ -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,
+25 -3
View File
@@ -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%;
+12 -12
View File
@@ -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>
);
}
+59 -30
View File
@@ -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;
}
+9 -6
View File
@@ -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>
+17 -4
View File
@@ -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;
}
+11 -1
View File
@@ -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;
}
+8 -2
View File
@@ -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}