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
13 changed files with 23 additions and 176 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} />
@@ -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 -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",
@@ -23,16 +23,11 @@ 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,
@@ -261,10 +256,6 @@ 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,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 = 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({
@@ -84,7 +73,6 @@ export function BeszelStatsView({
timePeriod,
visibility,
columns,
availableHeight,
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useI18n("widget.beszelSystemStats");
@@ -260,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,
@@ -296,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,
@@ -310,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,
@@ -324,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,
@@ -337,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,
@@ -352,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,
@@ -366,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,
@@ -380,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 = [
{