Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f8d12c19e | ||
|
|
10ba298233 | ||
|
|
7855278ed1 | ||
|
|
aa179f1ec0 |
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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user