Compare commits

..
Author SHA1 Message Date
ajnart ded5bf03bb fix: use valid widget inset token 2026-08-28 16:50:39 +02:00
ajnart f484306bb3 fix: inset widget edit action 2026-08-28 16:48:31 +02:00
ajnart 649008d327 fix: keep widget edit overlays visible 2026-08-28 16:36:05 +02:00
ajnart e716f847ab fix: rework responsive layout breakpoints 2026-08-28 15:44:25 +02:00
20 changed files with 120 additions and 283 deletions

No files matched your search

@@ -273,8 +273,6 @@ 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,7 +16,6 @@ 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"]} />
@@ -40,9 +39,6 @@ import mediaRequestList from "./img/list.png";
{
integration: overseerrIntegration,
},
{
integration: seerrIntegration,
},
]}
/>
@@ -50,7 +46,6 @@ 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,7 +16,6 @@ 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"]} />
@@ -32,9 +31,6 @@ Provides an overview of media requests, including totals, availability, approval
{
integration: overseerrIntegration,
},
{
integration: seerrIntegration,
},
]}
/>
@@ -47,7 +43,6 @@ 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: "downloads" })(board);
const result = createItemCallback({ kind: "mediaMissing" })(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: 2 }),
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }),
]);
});
@@ -262,7 +262,7 @@ describe("item actions create-item", () => {
layoutId: layout.id,
sectionId: "canvas",
width: 1,
height: 2,
height: 3,
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: calc(var(--homarr-z-index-board-header) - 1);
z-index: var(--homarr-z-index-widget-focus);
}
.board-grid-entry[data-grid-preview="true"] {
@@ -27,12 +27,6 @@ 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: 2, height: 2 });
).toMatchObject({ width: 4, height: 3 });
});
it("requires an exact board id when more than one board exists", async () => {
+1 -7
View File
@@ -63,19 +63,13 @@ 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 },
mediaServer: { width: 3, height: 2 },
"mediaRequests-requestList": { width: 3, height: 2 },
"mediaRequests-requestStats": { width: 2, height: 2 },
mediaMissing: { width: 2, height: 2 },
mediaMissing: { width: 4, height: 3 },
bazarr: { width: 2, height: 2 },
assistant: { width: 10, height: 4 },
};
+1 -9
View File
@@ -4227,11 +4227,7 @@
},
"mediaRequests-requestList": {
"name": "Media Requests List",
"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."
},
"description": "See a list of all media requests from your Overseerr or Jellyseerr instance",
"option": {
"linksTargetNewTab": {
"label": "Open links in new tab"
@@ -4271,10 +4267,6 @@
"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,16 +1,11 @@
.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;
}
.beszelStatsRoot:hover .beszelStatsControls,
.beszelStatsRoot:focus-within .beszelStatsControls {
.beszelStatsContainer:hover .beszelStatsControls,
.beszelStatsContainer:focus-within .beszelStatsControls {
opacity: 1;
pointer-events: auto;
}
@@ -23,16 +23,11 @@ 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,
@@ -71,12 +66,6 @@ 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"
@@ -191,83 +180,82 @@ export default function BeszelSystemStatsWidget({
}
return (
<Box h="100%" pos="relative" className={classes.beszelStatsRoot}>
<Box h="100%" pos="relative">
<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 p="sm">
<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>
)}
<BeszelStatsView
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
systemId={selectedSystem?.systemId ?? ""}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={responsiveWidth > 600 ? 2 : 1}
availableHeight={Math.max(0, responsiveHeight - STACK_PADDING - SCROLL_FIT_BUFFER)}
columns={width > 600 ? 2 : 1}
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" wrap="nowrap">
<Text size="sm" fw={600} style={{ flexShrink: 0 }}>
<Group gap="xs">
<Text size="sm" fw={600}>
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed" truncate style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed">
{subtitle}
</Text>
)}
@@ -35,13 +35,13 @@ const tooltips = {
bytesTotal: makeTooltipProps(formatStorageBytes, true),
};
const ChartSkeleton = ({ height }: { height: number }) => (
const ChartSkeleton = () => (
<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={height} radius="sm" />
<Skeleton h={CHART_HEIGHT} radius="sm" />
</Stack>
);
@@ -62,20 +62,9 @@ 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({
@@ -84,7 +73,6 @@ export function BeszelStatsView({
timePeriod,
visibility,
columns,
availableHeight,
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useI18n("widget.beszelSystemStats");
@@ -238,13 +226,10 @@ 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={effectiveColumns} spacing="md" aria-label={t("name")}>
<SimpleGrid cols={columns} spacing="md" aria-label={t("name")}>
{Array.from({ length: visibleChartCount }, (_, index) => (
<ChartSkeleton key={index} height={chartHeight} />
<ChartSkeleton key={index} />
))}
</SimpleGrid>
);
@@ -263,29 +248,14 @@ 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={effectiveColumns} spacing="md">
<SimpleGrid cols={columns} spacing="md">
{visibility.cpu && cpuData.length > 0 && (
<BeszelChartPanel
title={t("chart.cpu.title")}
subtitle={t("chart.cpu.subtitle")}
chartProps={{
h: chartHeight,
h: CHART_HEIGHT,
data: cpuData,
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
@@ -299,7 +269,7 @@ export function BeszelStatsView({
title={t("chart.memory.title")}
subtitle={t("chart.memory.subtitle")}
chartProps={{
h: chartHeight,
h: CHART_HEIGHT,
data: memoryData,
type: "stacked",
series: series.memory,
@@ -313,7 +283,7 @@ export function BeszelStatsView({
title={t("chart.disk.title")}
subtitle={t("chart.disk.subtitle")}
chartProps={{
h: chartHeight,
h: CHART_HEIGHT,
data: diskData,
type: "stacked",
series: diskSeries,
@@ -327,7 +297,7 @@ export function BeszelStatsView({
title={t("chart.diskIO.title")}
subtitle={t("chart.diskIO.subtitle")}
chartProps={{
h: chartHeight,
h: CHART_HEIGHT,
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
@@ -340,7 +310,7 @@ export function BeszelStatsView({
title={t("chart.network.title")}
subtitle={t("chart.network.subtitle")}
chartProps={{
h: chartHeight,
h: CHART_HEIGHT,
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
@@ -355,7 +325,7 @@ export function BeszelStatsView({
title={t("chart.dockerCpu.title")}
subtitle={t("chart.dockerCpu.subtitle")}
chartProps={{
h: chartHeight,
h: CHART_HEIGHT,
data: dockerCpuData,
type: "stacked",
series: containerSeries,
@@ -369,7 +339,7 @@ export function BeszelStatsView({
title={t("chart.dockerMemory.title")}
subtitle={t("chart.dockerMemory.subtitle")}
chartProps={{
h: chartHeight,
h: CHART_HEIGHT,
data: dockerMemoryData,
type: "stacked",
series: containerSeries,
@@ -383,7 +353,7 @@ export function BeszelStatsView({
title={t("chart.dockerNetwork.title")}
subtitle={t("chart.dockerNetwork.subtitle")}
chartProps={{
h: chartHeight,
h: CHART_HEIGHT,
data: dockerNetworkData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
+7 -24
View File
@@ -1,4 +1,3 @@
import { useMemo } from "react";
import { Box, Container, Flex, HoverCard, Text, useMantineTheme } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -14,25 +13,15 @@ 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 { textSize, indicatorSize, indicatorMarginTop } = useMemo(() => getDayScale(minAxisSize), [minAxisSize]);
const indicatorSize = shouldScaleDown ? 3 : 4;
const cell = (
<Container
@@ -50,15 +39,10 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
justifyContent: "center",
}}
>
<Text ta={"center"} size={textSize} lh={1}>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
</Text>
<NotificationIndicator
events={events}
size={indicatorSize}
marginTop={indicatorMarginTop}
visible={shouldShowIndicators}
/>
<NotificationIndicator events={events} size={indicatorSize} visible={shouldShowIndicators} />
</Container>
);
@@ -86,20 +70,19 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
interface NotificationIndicatorProps {
events: CalendarEvent[];
size: number;
marginTop: number;
visible: boolean;
}
const NotificationIndicator = ({ events, size, marginTop, visible }: NotificationIndicatorProps) => {
const NotificationIndicator = ({ events, size, visible }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(
(color): color is string => Boolean(color),
);
if (!visible || notificationEvents.length === 0) return null;
if (!visible) return null;
return (
<Flex
mt={marginTop}
mt={3}
w="fit-content"
maw="75%"
h={size}
@@ -212,7 +212,6 @@ const CalendarBase = ({
monthCell: {
textAlign: "center",
position: "relative",
padding: 3,
},
day: {
borderRadius: actualItemRadius,
@@ -1,49 +0,0 @@
"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 { MediaRequestsEmptyState } from "../empty-state";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css";
import searchClasses from "../search-button.module.css";
@@ -30,7 +30,6 @@ 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))
@@ -49,16 +48,7 @@ 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) {
return (
<MediaRequestsEmptyState
title={t("empty.title")}
description={t("empty.description")}
integrationIds={integrationIds}
isEditMode={isEditMode}
/>
);
}
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
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 { MediaRequestsEmptyState } from "../empty-state";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css";
import searchClasses from "../search-button.module.css";
@@ -54,16 +54,8 @@ export default function MediaServerWidget({
requestStats.users.length === 0 &&
requestStats.stats.length === 0 &&
requestStats.failedIntegrations.length === 0
) {
return (
<MediaRequestsEmptyState
title={t("empty.title")}
description={t("empty.description")}
integrationIds={integrationIds}
isEditMode={isEditMode}
/>
);
}
)
throw new NoIntegrationDataError();
const data = [
{
@@ -4,8 +4,8 @@
flex-direction: column;
height: 100%;
min-height: 0;
padding: clamp(6px, 3cqmin, 14px);
gap: clamp(4px, 2cqmin, 10px);
padding: clamp(6px, 3cqi, 14px);
gap: clamp(4px, 2cqi, 10px);
overflow: hidden;
}
@@ -13,10 +13,11 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(8px, 3cqmin, 18px);
gap: clamp(8px, 3cqi, 18px);
flex-shrink: 0;
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));
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));
}
.heroExpanded {
@@ -36,25 +37,25 @@
}
.heroTextOnly .heroValue {
font-size: clamp(22px, 10cqmin, 36px);
font-size: clamp(22px, 10cqi, 36px);
}
.heroText {
display: flex;
flex-direction: column;
gap: clamp(2px, 1cqmin, 4px);
gap: clamp(2px, 1cqi, 4px);
min-width: 0;
flex: 1;
}
.heroLabel {
font-size: clamp(9px, 2.8cqmin, 13px);
font-size: clamp(9px, 2.8cqi, 13px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
}
.heroValue {
font-size: clamp(18px, 8cqmin, 32px);
font-size: clamp(18px, 8cqi, 32px);
font-weight: 700;
line-height: 1.1;
}
@@ -67,7 +68,7 @@
display: grid;
flex: 1;
min-height: 0;
gap: clamp(4px, 2cqmin, 10px);
gap: clamp(4px, 2cqi, 10px);
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
grid-auto-rows: 1fr;
align-content: stretch;
@@ -80,10 +81,11 @@
justify-content: center;
min-height: 0;
min-width: 0;
gap: clamp(2px, 1.5cqmin, 5px);
padding: clamp(4px, 2.5cqmin, 10px);
gap: clamp(2px, 1.5cqi, 5px);
padding: clamp(4px, 2.5cqi, 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;
}
@@ -93,14 +95,14 @@
}
.statValue {
font-size: clamp(14px, 5.5cqmin, 24px);
font-size: clamp(14px, 5.5cqi, 24px);
font-weight: 700;
line-height: 1.1;
text-align: center;
}
.statLabel {
font-size: clamp(8px, 2.5cqmin, 12px);
font-size: clamp(8px, 2.5cqi, 12px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
@@ -117,6 +119,6 @@
justify-content: center;
min-height: 0;
color: var(--mantine-color-dimmed);
font-size: clamp(11px, 3cqmin, 14px);
font-size: clamp(11px, 3cqi, 14px);
text-align: center;
}
@@ -129,9 +129,8 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
);
const uptimeValue = clampPercent(combined.averageUptimePercent);
const uptimeColor = getUptimeColor(uptimeValue);
const visualScale = Math.min(width, height);
const ringSize = getRingSize(visualScale);
const iconSize = getIconSize(visualScale);
const ringSize = getRingSize(width);
const iconSize = getIconSize(width);
const gridCols = getGridCols(width);
const isAdvanced = displayMode === "advanced";
@@ -248,7 +247,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
</Group>
<ScrollArea h="100%">
<Stack gap="lg" p="md">
<Box h={Math.min(420, Math.max(180, Math.floor(height * 0.55)))}>{summaryContent}</Box>
<Box h={Math.max(280, Math.min(420, height - 120))}>{summaryContent}</Box>
{monitorList}
</Stack>
</ScrollArea>