Compare commits

..
Author SHA1 Message Date
ajnart 1f8d12c19e fix(widgets): use readable onboarding sizes 2026-08-29 06:30:19 +02:00
Bergasha 10ba298233 fix(board): beszel & calendar scale fix (#6737)
* fix(calendar): add cell spacing and scale day content with widget size

* fix(beszel): stop chart panels from leaving blank grid columns and size charts to fit the widget

* fix(widgets): address CodeRabbit review on calendar/beszel scaling PR

- beszel: account for the SimpleGrid row gap and the Stack gap between
  the controls row and the grid in the available-height budget, so a
  two-row layout no longer overflows the widget.
- calendar: memoize the day-cell scale lookup instead of recomputing
  it on every render.
- calendar: don't render the notification indicator row when none of
  a day's events have an indicatorColor, which was shifting the date
  number up with no visible dot to show for it.

Skipped one finding: the claim that .day's absolute inset:0 covers
monthCell's padding. It doesn't - the containing block for an
absolutely positioned element is the padding edge of its positioned
ancestor per the CSS spec, so the padding correctly insets .day. This
was also visually verified in the browser while building the fix.

* fix(beszel): don't treat availableHeight=0 as omitted in computeChartHeight

A falsy check on availableHeight also caught the legitimate value 0
(widget shorter than its own padding+controls), falling back to the
full 180px height instead of clamping to MIN_CHART_HEIGHT - the exact
overflow this height budget was meant to prevent.
2026-08-29 11:02:10 +10:00
Bergasha 7855278ed1 fix(board): give AppShell root a viewport-height floor (#6736)
* fix(board): give AppShell root a viewport-height floor when a background is set

The board canvas sizes itself to its content, and AppShell runs in
'static' mode so <main> doesn't force a 100dvh minimum either. That
means a board's background image only covers as much height as the
content needs - collapsing a container (or any board with short
content) leaves flat page background showing below it instead of the
background continuing to the bottom of the viewport, looking like a
big empty void.

Root-caused via a local repro: the grid/reflow system was already
shrinking the collapsed container's own space correctly (verified the
live-measured canvas height matched exactly, not the stale debug
attribute) - the void was purely a background-sizing issue on the
AppShell root, unrelated to the collapse/layout logic itself.

* docs(boards): document background minimum-height behavior
2026-08-29 09:26:15 +10:00
Thomas aa179f1ec0 fix: decouple responsive layout roles from breakpoints
* fix: rework responsive layout breakpoints

* fix: keep widget edit overlays visible

* fix: inset widget edit action

* fix: use valid widget inset token
2026-08-28 17:11:51 +02:00
13 changed files with 176 additions and 23 deletions

No files matched your search

@@ -273,6 +273,8 @@ Expanding it smoothly restores those bounds and moves following items out of the
### Background
When a background image is set, it always covers at least the full browser window height, even if the board's content is shorter (for example when a Container is collapsed).
#### Background image URL
Homarr can display a background image behind apps and widgets.
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import { seerrIntegration } from "@site/docs/integrations/seerr";
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
@@ -39,6 +40,9 @@ import mediaRequestList from "./img/list.png";
{
integration: overseerrIntegration,
},
{
integration: seerrIntegration,
},
]}
/>
@@ -46,6 +50,7 @@ import mediaRequestList from "./img/list.png";
- Approve / decline a request by clicking on the thumbs up or down icon.
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
- When no requests exist yet, the widget offers the same media search action instead of reporting an integration error.
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
<WidgetInteractionGuide advancedView={false} />
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
import { mediaRequestStatsWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import { seerrIntegration } from "@site/docs/integrations/seerr";
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
@@ -31,6 +32,9 @@ Provides an overview of media requests, including totals, availability, approval
{
integration: overseerrIntegration,
},
{
integration: seerrIntegration,
},
]}
/>
@@ -43,6 +47,7 @@ import mediaRequestStats from "./img/stats.png";
### Interactions
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
- When no requests exist yet, the widget offers media search and explains when statistics will appear.
- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
<WidgetInteractionGuide advancedView={false} />
@@ -27,6 +27,12 @@ export const ClientShell = ({
return (
<AppShell
{...backgroundProps}
// The board canvas sizes itself to its content (AppShell runs in "static" mode so
// <main> doesn't force a 100dvh minimum - see AppShell.css). Without this, a board
// background image only covers as much height as the content needs, so collapsing a
// container short enough leaves flat page background showing below it instead of the
// background continuing to the bottom of the viewport.
mih={backgroundProps.bg ? "100dvh" : undefined}
header={hasHeader ? { height: headerHeight } : undefined}
navbar={
hasNavigation
+7 -1
View File
@@ -63,13 +63,19 @@ export type WidgetKind = (typeof widgetKinds)[number];
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
airQuality: { width: 2, height: 1 },
calendar: { width: 2, height: 2 },
countdown: { width: 2, height: 1 },
downloads: { width: 4, height: 2 },
indexerManager: { width: 2, height: 3 },
timer: { width: 2, height: 1 },
uptimeKuma: { width: 2, height: 3 },
audioStats: { width: 2, height: 2 },
paperlessNgx: { width: 2, height: 2 },
patchmon: { width: 2, height: 2 },
mediaMissing: { width: 4, height: 3 },
mediaServer: { width: 3, height: 2 },
"mediaRequests-requestList": { width: 3, height: 2 },
"mediaRequests-requestStats": { width: 2, height: 2 },
mediaMissing: { width: 2, height: 2 },
bazarr: { width: 2, height: 2 },
assistant: { width: 10, height: 4 },
};
+9 -1
View File
@@ -4227,7 +4227,11 @@
},
"mediaRequests-requestList": {
"name": "Media Requests List",
"description": "See a list of all media requests from your Overseerr or Jellyseerr instance",
"description": "See a list of all media requests from your Seerr, Overseerr, or Jellyseerr instance",
"empty": {
"title": "No media requests yet",
"description": "Search for a movie or show to start a request."
},
"option": {
"linksTargetNewTab": {
"label": "Open links in new tab"
@@ -4267,6 +4271,10 @@
"mediaRequests-requestStats": {
"name": "Media Requests Stats",
"description": "Statistics about your media requests",
"empty": {
"title": "No request activity yet",
"description": "Stats will appear after the first media request."
},
"titles": {
"stats": {
"main": "Media Stats",
@@ -23,11 +23,16 @@ import { getUsableWidgetQueryData } from "../common/query-state";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
const CONTROLS_ROW_HEIGHT = 36;
const CONTROLS_ROW_GAP = 16;
const STACK_PADDING = 24;
export default function BeszelSystemStatsWidget({
options,
integrationIds,
isEditMode,
width,
height,
boardId,
itemId,
setOptions,
@@ -256,6 +261,10 @@ export default function BeszelSystemStatsWidget({
systemId={selectedSystem?.systemId ?? ""}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1}
availableHeight={Math.max(
0,
height - STACK_PADDING - (isEditMode ? 0 : CONTROLS_ROW_HEIGHT + CONTROLS_ROW_GAP),
)}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
@@ -62,9 +62,20 @@ interface BeszelStatsViewProps {
timePeriod: BeszelTimePeriod;
visibility: BeszelStatsVisibility;
columns: 1 | 2;
availableHeight?: number;
onSwitchToHistorical?: () => void;
}
const MIN_CHART_HEIGHT = 100;
const PANEL_ROW_OVERHEAD = 28;
const GRID_ROW_GAP = 16;
const computeChartHeight = (availableHeight: number | undefined, rowCount: number) => {
if (availableHeight === undefined || rowCount <= 0) return CHART_HEIGHT;
const perRow = (availableHeight - (rowCount - 1) * GRID_ROW_GAP) / rowCount - PANEL_ROW_OVERHEAD;
return Math.max(MIN_CHART_HEIGHT, Math.min(CHART_HEIGHT, Math.floor(perRow)));
};
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
export function BeszelStatsView({
@@ -73,6 +84,7 @@ export function BeszelStatsView({
timePeriod,
visibility,
columns,
availableHeight,
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useI18n("widget.beszelSystemStats");
@@ -248,14 +260,29 @@ export function BeszelStatsView({
);
}
const renderedPanelCount =
(visibility.cpu && cpuData.length > 0 ? 1 : 0) +
(visibility.memory && memoryData.length > 0 ? 1 : 0) +
(visibility.disk && diskData.length > 0 ? 1 : 0) +
(visibility.diskIO && diskIOData.length > 0 ? 1 : 0) +
(visibility.network && networkData.length > 0 ? 1 : 0) +
(showDocker && containerSeries.length > 0
? (visibility.dockerCpu && dockerCpuData.length > 0 ? 1 : 0) +
(visibility.dockerMemory && dockerMemoryData.length > 0 ? 1 : 0) +
(visibility.dockerNetwork && dockerNetworkData.length > 0 ? 1 : 0)
: 0);
const effectiveColumns = Math.min(columns, Math.max(1, renderedPanelCount)) as 1 | 2;
const rowCount = Math.ceil(renderedPanelCount / effectiveColumns);
const chartHeight = computeChartHeight(availableHeight, rowCount);
return (
<SimpleGrid cols={columns} spacing="md">
<SimpleGrid cols={effectiveColumns} spacing="md">
{visibility.cpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
@@ -269,7 +296,7 @@ export function BeszelStatsView({
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
data: memoryData,
type: "stacked",
series: series.memory,
@@ -283,7 +310,7 @@ export function BeszelStatsView({
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
data: diskData,
type: "stacked",
series: diskSeries,
@@ -297,7 +324,7 @@ export function BeszelStatsView({
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
@@ -310,7 +337,7 @@ export function BeszelStatsView({
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
@@ -325,7 +352,7 @@ export function BeszelStatsView({
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
@@ -339,7 +366,7 @@ export function BeszelStatsView({
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
data: dockerMemoryData,
type: "stacked",
series: containerSeries,
@@ -353,7 +380,7 @@ export function BeszelStatsView({
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: CHART_HEIGHT,
h: chartHeight,
data: dockerNetworkData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
+24 -7
View File
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { Box, Container, Flex, HoverCard, Text, useMantineTheme } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -13,15 +14,25 @@ interface CalendarDayProps {
rootHeight: number;
}
const fallbackDayScale = { textSize: "xs" as const, indicatorSize: 3, indicatorMarginTop: 2 };
const dayScaleTiers = [
{ minAxisSize: 700, textSize: "lg" as const, indicatorSize: 6, indicatorMarginTop: 6 },
{ minAxisSize: 450, textSize: "md" as const, indicatorSize: 5, indicatorMarginTop: 4 },
{ minAxisSize: 250, textSize: "sm" as const, indicatorSize: 4, indicatorMarginTop: 3 },
{ minAxisSize: 0, ...fallbackDayScale },
];
const getDayScale = (minAxisSize: number) =>
dayScaleTiers.find((tier) => minAxisSize >= tier.minAxisSize) ?? fallbackDayScale;
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
const board = useRequiredBoard();
const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius];
const minAxisSize = Math.min(rootWidth, rootHeight);
const shouldScaleDown = minAxisSize < 350;
const shouldShowIndicators = rootHeight >= 256;
const indicatorSize = shouldScaleDown ? 3 : 4;
const { textSize, indicatorSize, indicatorMarginTop } = useMemo(() => getDayScale(minAxisSize), [minAxisSize]);
const cell = (
<Container
@@ -39,10 +50,15 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
justifyContent: "center",
}}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
<Text ta={"center"} size={textSize} lh={1}>
{date.getDate()}
</Text>
<NotificationIndicator events={events} size={indicatorSize} visible={shouldShowIndicators} />
<NotificationIndicator
events={events}
size={indicatorSize}
marginTop={indicatorMarginTop}
visible={shouldShowIndicators}
/>
</Container>
);
@@ -70,19 +86,20 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
interface NotificationIndicatorProps {
events: CalendarEvent[];
size: number;
marginTop: number;
visible: boolean;
}
const NotificationIndicator = ({ events, size, visible }: NotificationIndicatorProps) => {
const NotificationIndicator = ({ events, size, marginTop, visible }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(
(color): color is string => Boolean(color),
);
if (!visible) return null;
if (!visible || notificationEvents.length === 0) return null;
return (
<Flex
mt={3}
mt={marginTop}
w="fit-content"
maw="75%"
h={size}
@@ -212,6 +212,7 @@ const CalendarBase = ({
monthCell: {
textAlign: "center",
position: "relative",
padding: 3,
},
day: {
borderRadius: actualItemRadius,
@@ -0,0 +1,49 @@
"use client";
import { Button, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconMovieOff, IconSearch } from "@tabler/icons-react";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
interface MediaRequestsEmptyStateProps {
title: string;
description: string;
integrationIds: string[];
isEditMode: boolean;
}
export const MediaRequestsEmptyState = ({
title,
description,
integrationIds,
isEditMode,
}: MediaRequestsEmptyStateProps) => {
const tSearch = useI18n("search.mode.media");
return (
<Stack h="100%" align="center" justify="center" gap="xs" p="md">
<ThemeIcon variant="light" size="xl" radius="xl">
<IconMovieOff />
</ThemeIcon>
<Stack gap={2} align="center">
<Text fw={600} ta="center">
{title}
</Text>
<Text c="dimmed" size="sm" ta="center" maw="28ch">
{description}
</Text>
</Stack>
{!isEditMode ? (
<Button
variant="light"
size="compact-sm"
leftSection={<IconSearch size="var(--mantine-font-size-md)" />}
onClick={() => openMediaRequestSearch({ integrationIds })}
>
{tSearch("action.search.label")}
</Button>
) : null}
</Stack>
);
};
@@ -20,7 +20,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import { MediaRequestsEmptyState } from "../empty-state";
import classes from "./component.module.css";
import searchClasses from "../search-button.module.css";
@@ -30,6 +30,7 @@ export default function MediaServerWidget({
options,
width,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const t = useI18n("widget.mediaRequests-requestList");
const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess()
.filter(({ id }) => integrationIds.includes(id))
@@ -48,7 +49,16 @@ export default function MediaServerWidget({
if (isInitialWidgetQueryPending(mediaRequestQuery)) return <WidgetQueryLoadingState />;
if (!mediaRequestData) return <WidgetEmptyState />;
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
if (mediaRequests.length === 0 && failedIntegrations.length === 0) {
return (
<MediaRequestsEmptyState
title={t("empty.title")}
description={t("empty.description")}
integrationIds={integrationIds}
isEditMode={isEditMode}
/>
);
}
const showIntegrationSource = new Set(mediaRequests.map(({ integrationId }) => integrationId)).size > 1;
return (
@@ -28,7 +28,7 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import { MediaRequestsEmptyState } from "../empty-state";
import classes from "./component.module.css";
import searchClasses from "../search-button.module.css";
@@ -54,8 +54,16 @@ export default function MediaServerWidget({
requestStats.users.length === 0 &&
requestStats.stats.length === 0 &&
requestStats.failedIntegrations.length === 0
)
throw new NoIntegrationDataError();
) {
return (
<MediaRequestsEmptyState
title={t("empty.title")}
description={t("empty.description")}
integrationIds={integrationIds}
isEditMode={isEditMode}
/>
);
}
const data = [
{