Compare commits

..
Author SHA1 Message Date
ajnart 2b28c3230c test(board): align canonical widget size assertions 2026-08-29 08:33:11 +02:00
ajnart dc600fc5c8 fix(board): improve widget scaling and edit layering 2026-08-29 07:59:06 +02:00
Bergasha ea250d418d Revert "feat(header): add opt-in auto-hide-on-scroll for the header (#6738)" (#6740)
This reverts commit e58c229664.
2026-08-29 15:19:20 +10:00
Bergasha e58c229664 feat(header): add opt-in auto-hide-on-scroll for the header (#6738)
* feat(header): add opt-in auto-hide-on-scroll for the header

New per-user header preference (default off): when enabled, the
header slides away while scrolling down and reappears on scroll-up or
near the top of the page. Disabled while in board edit mode, and
skipped entirely while focus is inside the header. Only applies when
the header is set to visible - the existing hover-to-reveal floating
controls already cover the hidden-header case.

* chore(api): regenerate the checked-in OpenAPI schema

check:openapi was failing in CI - the schema on disk hadn't been
regenerated after the header preferences gained autoHideOnScroll.

Add scripts/generate-openapi-schema.mts (and a generate:openapi
package script) alongside the existing check script so this is a
one-command fix next time the schema drifts.

* docs(users): document the header studio's visibility and auto-hide options

Header customization (drag-to-arrange controls, hide/show, hover-reveal
and pin, auto-hide-on-scroll) had no documentation on the user general
settings page.
2026-08-29 14:56:02 +10:00
Thomas ee08beef59 fix(widgets): use readable onboarding sizes (#6739) 2026-08-29 06:55:31 +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
20 changed files with 283 additions and 120 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} />
@@ -21,7 +21,7 @@ describe("item actions create-item", () => {
.build();
const baseLayout = board.layouts.find((layout) => layout.role === "base");
const result = createItemCallback({ kind: "mediaMissing" })(board);
const result = createItemCallback({ kind: "downloads" })(board);
const createdItem = result.items.at(0);
expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3);
@@ -165,7 +165,7 @@ describe("item actions create-item", () => {
const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board);
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }),
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 2 }),
]);
});
@@ -262,7 +262,7 @@ describe("item actions create-item", () => {
layoutId: layout.id,
sectionId: "canvas",
width: 1,
height: 3,
height: 2,
xOffset: 0,
yOffset: 1,
}),
@@ -13,7 +13,7 @@
.board-grid-entry:has([data-board-widget-header]):hover,
.board-grid-entry:has([data-board-widget-header]):focus-within {
z-index: var(--homarr-z-index-widget-focus);
z-index: calc(var(--homarr-z-index-board-header) - 1);
}
.board-grid-entry[data-grid-preview="true"] {
@@ -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
+1 -1
View File
@@ -280,7 +280,7 @@ describe("onboard.completeSetup", () => {
expect(mediaMissing).toBeDefined();
expect(
await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, mediaMissing?.id ?? "missing") }),
).toMatchObject({ width: 4, height: 3 });
).toMatchObject({ width: 2, height: 2 });
});
it("requires an exact board id when more than one board exists", async () => {
+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",
@@ -1,11 +1,16 @@
.beszelStatsControls {
position: absolute;
z-index: 2;
top: var(--mantine-spacing-sm);
right: var(--mantine-spacing-sm);
left: var(--mantine-spacing-sm);
opacity: 0;
transition: opacity 150ms ease-out;
pointer-events: none;
}
.beszelStatsContainer:hover .beszelStatsControls,
.beszelStatsContainer:focus-within .beszelStatsControls {
.beszelStatsRoot:hover .beszelStatsControls,
.beszelStatsRoot:focus-within .beszelStatsControls {
opacity: 1;
pointer-events: auto;
}
@@ -23,11 +23,16 @@ import { getUsableWidgetQueryData } from "../common/query-state";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
const STACK_PADDING = 24;
const SCROLL_FIT_BUFFER = 2;
export default function BeszelSystemStatsWidget({
options,
integrationIds,
isEditMode,
width,
height,
displayScale = 1,
boardId,
itemId,
setOptions,
@@ -66,6 +71,12 @@ export default function BeszelSystemStatsWidget({
{ value: "1w", label: t("period.oneWeek") },
{ value: "30d", label: t("period.thirtyDays") },
];
let responsiveWidth = width;
let responsiveHeight = height;
if (Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
useWidgetRuntimeQueries(
widgetRuntimeRef,
selectedSystem && options.timePeriod !== "1m"
@@ -180,82 +191,83 @@ export default function BeszelSystemStatsWidget({
}
return (
<Box h="100%" pos="relative">
<Box h="100%" pos="relative" className={classes.beszelStatsRoot}>
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<Group gap={0}>
<IntegrationErrorIndicator results={systemsResult} />
</Group>
</Box>
{!isEditMode && (
<Group gap="xs" wrap="nowrap" className={classes.beszelStatsControls}>
{systems.length > 1 && (
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
<Menu.Target>
<Button
variant="default"
size="compact-xs"
leftSection={<IconServer style={iconSizes.sm} />}
className={classes.beszelStatsSystemToggle}
disabled={isSelectionSavePending}
>
{selectedLabel}
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((system) => (
<Menu.Item
key={system.value}
fz="xs"
fw={system.value === selectedValue ? 600 : 400}
c={system.value !== selectedValue ? "dimmed" : undefined}
disabled={isSelectionSavePending}
onClick={() => handleSelectSystem(system.value)}
>
{system.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{responsiveWidth >= 560 ? (
<Button.Group className={classes.beszelStatsPeriodToggle}>
{periodOptions.map((period) => (
<Button
key={period.value}
size="compact-xs"
variant={period.value === options.timePeriod ? "filled" : "default"}
className={classes.beszelStatsPeriodButton}
aria-pressed={period.value === options.timePeriod}
disabled={isSelectionSavePending}
onClick={() => handleTimePeriod(period.value)}
>
{period.label}
</Button>
))}
</Button.Group>
) : (
<Select
size="xs"
value={options.timePeriod}
onChange={(value) => value && handleTimePeriod(value)}
disabled={isSelectionSavePending}
data={periodOptions}
className={classes.beszelStatsPeriodToggle}
/>
)}
</Group>
)}
<ScrollArea
h="100%"
className={classes.beszelStatsContainer}
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap="md" p="sm">
{!isEditMode && (
<Group gap="xs" wrap="nowrap" className={classes.beszelStatsControls}>
{systems.length > 1 && (
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
<Menu.Target>
<Button
variant="default"
size="compact-xs"
leftSection={<IconServer style={iconSizes.sm} />}
className={classes.beszelStatsSystemToggle}
disabled={isSelectionSavePending}
>
{selectedLabel}
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((system) => (
<Menu.Item
key={system.value}
fz="xs"
fw={system.value === selectedValue ? 600 : 400}
c={system.value !== selectedValue ? "dimmed" : undefined}
disabled={isSelectionSavePending}
onClick={() => handleSelectSystem(system.value)}
>
{system.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{width >= 560 ? (
<Button.Group className={classes.beszelStatsPeriodToggle}>
{periodOptions.map((period) => (
<Button
key={period.value}
size="compact-xs"
variant={period.value === options.timePeriod ? "filled" : "default"}
className={classes.beszelStatsPeriodButton}
aria-pressed={period.value === options.timePeriod}
disabled={isSelectionSavePending}
onClick={() => handleTimePeriod(period.value)}
>
{period.label}
</Button>
))}
</Button.Group>
) : (
<Select
size="xs"
value={options.timePeriod}
onChange={(value) => value && handleTimePeriod(value)}
disabled={isSelectionSavePending}
data={periodOptions}
className={classes.beszelStatsPeriodToggle}
/>
)}
</Group>
)}
<Stack p="sm">
<BeszelStatsView
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
systemId={selectedSystem?.systemId ?? ""}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={width > 600 ? 2 : 1}
columns={responsiveWidth > 600 ? 2 : 1}
availableHeight={Math.max(0, responsiveHeight - STACK_PADDING - SCROLL_FIT_BUFFER)}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
@@ -101,12 +101,12 @@ interface BeszelChartPanelProps {
export const BeszelChartPanel = memo(({ title, subtitle, chartProps }: BeszelChartPanelProps) => (
<Stack gap={4} style={panelStyle}>
<Group gap="xs">
<Text size="sm" fw={600}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} style={{ flexShrink: 0 }}>
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
<Text size="xs" c="dimmed" truncate style={{ minWidth: 0 }}>
{subtitle}
</Text>
)}
@@ -35,13 +35,13 @@ const tooltips = {
bytesTotal: makeTooltipProps(formatStorageBytes, true),
};
const ChartSkeleton = () => (
const ChartSkeleton = ({ height }: { height: number }) => (
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs">
<Skeleton h={14} w={72} radius="sm" />
<Skeleton h={10} w={110} radius="sm" />
</Group>
<Skeleton h={CHART_HEIGHT} radius="sm" />
<Skeleton h={height} radius="sm" />
</Stack>
);
@@ -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 = 25;
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");
@@ -226,10 +238,13 @@ export function BeszelStatsView({
if (!activeStats) {
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
const effectiveColumns = Math.min(columns, Math.max(1, visibleChartCount)) as 1 | 2;
const rowCount = Math.ceil(visibleChartCount / effectiveColumns);
const chartHeight = computeChartHeight(availableHeight, rowCount);
return (
<SimpleGrid cols={columns} spacing="md" aria-label={t("name")}>
<SimpleGrid cols={effectiveColumns} spacing="md" aria-label={t("name")}>
{Array.from({ length: visibleChartCount }, (_, index) => (
<ChartSkeleton key={index} />
<ChartSkeleton key={index} height={chartHeight} />
))}
</SimpleGrid>
);
@@ -248,14 +263,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 +299,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 +313,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 +327,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 +340,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 +355,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 +369,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 +383,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 = [
{
@@ -4,8 +4,8 @@
flex-direction: column;
height: 100%;
min-height: 0;
padding: clamp(6px, 3cqi, 14px);
gap: clamp(4px, 2cqi, 10px);
padding: clamp(6px, 3cqmin, 14px);
gap: clamp(4px, 2cqmin, 10px);
overflow: hidden;
}
@@ -13,11 +13,10 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(8px, 3cqi, 18px);
gap: clamp(8px, 3cqmin, 18px);
flex-shrink: 0;
padding-bottom: clamp(4px, 2cqi, 10px);
border-bottom: 1px solid
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
padding-bottom: clamp(4px, 2cqmin, 10px);
border-bottom: 1px solid rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
}
.heroExpanded {
@@ -37,25 +36,25 @@
}
.heroTextOnly .heroValue {
font-size: clamp(22px, 10cqi, 36px);
font-size: clamp(22px, 10cqmin, 36px);
}
.heroText {
display: flex;
flex-direction: column;
gap: clamp(2px, 1cqi, 4px);
gap: clamp(2px, 1cqmin, 4px);
min-width: 0;
flex: 1;
}
.heroLabel {
font-size: clamp(9px, 2.8cqi, 13px);
font-size: clamp(9px, 2.8cqmin, 13px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
}
.heroValue {
font-size: clamp(18px, 8cqi, 32px);
font-size: clamp(18px, 8cqmin, 32px);
font-weight: 700;
line-height: 1.1;
}
@@ -68,7 +67,7 @@
display: grid;
flex: 1;
min-height: 0;
gap: clamp(4px, 2cqi, 10px);
gap: clamp(4px, 2cqmin, 10px);
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
grid-auto-rows: 1fr;
align-content: stretch;
@@ -81,11 +80,10 @@
justify-content: center;
min-height: 0;
min-width: 0;
gap: clamp(2px, 1.5cqi, 5px);
padding: clamp(4px, 2.5cqi, 10px);
gap: clamp(2px, 1.5cqmin, 5px);
padding: clamp(4px, 2.5cqmin, 10px);
border-radius: var(--mantine-radius-md);
border: 1px solid
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
border: 1px solid rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent;
overflow: hidden;
}
@@ -95,14 +93,14 @@
}
.statValue {
font-size: clamp(14px, 5.5cqi, 24px);
font-size: clamp(14px, 5.5cqmin, 24px);
font-weight: 700;
line-height: 1.1;
text-align: center;
}
.statLabel {
font-size: clamp(8px, 2.5cqi, 12px);
font-size: clamp(8px, 2.5cqmin, 12px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
@@ -119,6 +117,6 @@
justify-content: center;
min-height: 0;
color: var(--mantine-color-dimmed);
font-size: clamp(11px, 3cqi, 14px);
font-size: clamp(11px, 3cqmin, 14px);
text-align: center;
}
@@ -129,8 +129,9 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
);
const uptimeValue = clampPercent(combined.averageUptimePercent);
const uptimeColor = getUptimeColor(uptimeValue);
const ringSize = getRingSize(width);
const iconSize = getIconSize(width);
const visualScale = Math.min(width, height);
const ringSize = getRingSize(visualScale);
const iconSize = getIconSize(visualScale);
const gridCols = getGridCols(width);
const isAdvanced = displayMode === "advanced";
@@ -247,7 +248,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
</Group>
<ScrollArea h="100%">
<Stack gap="lg" p="md">
<Box h={Math.max(280, Math.min(420, height - 120))}>{summaryContent}</Box>
<Box h={Math.min(420, Math.max(180, Math.floor(height * 0.55)))}>{summaryContent}</Box>
{monitorList}
</Stack>
</ScrollArea>