diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 3fa95d2d4..89f061f1e 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -133,20 +133,21 @@ On larger canvases, the complete board grows together like browser zoom. The complete canvas always fits the available width and never adds horizontal board scrolling. - **Mobile** starts at `0px` and uses three columns on new boards. -- **Base** is the widest layout and starts at `768px` on new boards. +- **Base** is the main layout and starts at `768px` on new boards. Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed -rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between -their breakpoints. +rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts at any +unused breakpoint above Mobile. Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in -Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a -free breakpoint between Mobile and Base, and you can then change the breakpoint, columns, and rails. +Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses an +available breakpoint, and you can then change the breakpoint, columns, and rails. Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile -layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain -custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions. +layout. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest +becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them. +Duplicate breakpoints are normalized without discarding saved positions. Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept. @@ -182,7 +183,8 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200` #### Breakpoint The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at -`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint. +`0px`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the +eligible layout with the highest breakpoint, regardless of its role. When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent fallback to select a layout. @@ -271,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. diff --git a/apps/docs/docs/widgets/media-request-list/index.mdx b/apps/docs/docs/widgets/media-request-list/index.mdx index 2ed31ea88..9a4599a3d 100644 --- a/apps/docs/docs/widgets/media-request-list/index.mdx +++ b/apps/docs/docs/widgets/media-request-list/index.mdx @@ -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"; @@ -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. diff --git a/apps/docs/docs/widgets/media-request-stats/index.mdx b/apps/docs/docs/widgets/media-request-stats/index.mdx index 7376bb0e4..7a06bf365 100644 --- a/apps/docs/docs/widgets/media-request-stats/index.mdx +++ b/apps/docs/docs/widgets/media-request-stats/index.mdx @@ -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"; @@ -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. diff --git a/apps/docs/docs/widgets/speedtest-tracker/index.mdx b/apps/docs/docs/widgets/speedtest-tracker/index.mdx index 2912946e5..95cf296cb 100644 --- a/apps/docs/docs/widgets/speedtest-tracker/index.mdx +++ b/apps/docs/docs/widgets/speedtest-tracker/index.mdx @@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance, including the latest result, historical averages, and a live chart of the last 12 hours. -Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected. +The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected. When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown. diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index fa72af452..bf8cfc00f 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -40,6 +40,8 @@ interface Props { saveSettingsAsync: () => Promise; } +const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const; + export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => { const tBoard = useI18n("board"); const tLayout = useI18n("layout"); @@ -101,6 +103,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts); const baseLayout = form.values.layouts.find((layout) => layout.role === "base"); + const displayedLayouts = form.values.layouts + .map((layout, index) => ({ layout, index })) + .toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]); return ( @@ -134,13 +139,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync - {form.values.layouts.map((layout, index) => { + {displayedLayouts.map(({ layout, index }) => { const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id); const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base"); - const customBreakpoints = form.values.layouts - .filter((candidate) => candidate.role === "custom" && candidate.id !== layout.id) - .map((candidate) => candidate.breakpoint); - const baseBreakpoint = form.values.layouts.find((candidate) => candidate.role === "base")?.breakpoint ?? 768; return (
breakpoint + 1)) - : 1 - } - max={layout.role === "custom" ? baseBreakpoint - 1 : 32767} + min={layout.role === "mobile" ? 0 : 1} + max={32767} /> diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index 452c22699..ad753f8d1 100644 --- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -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, }), diff --git a/apps/nextjs/src/components/board/items/item-content.module.css b/apps/nextjs/src/components/board/items/item-content.module.css index c00ac34d4..3e30cd129 100644 --- a/apps/nextjs/src/components/board/items/item-content.module.css +++ b/apps/nextjs/src/components/board/items/item-content.module.css @@ -1,7 +1,7 @@ .settingsButton { position: absolute; - top: -34px; - right: 14px; + top: calc(var(--mantine-spacing-sm) / 2); + right: calc(var(--mantine-spacing-sm) / 2); z-index: 201; opacity: 0; pointer-events: none; @@ -52,7 +52,6 @@ @media (max-width: 768px) { .settingsButton { - right: 4px; opacity: 1; pointer-events: auto; } diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css index 25a8708f9..5d8e95f59 100644 --- a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css +++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css @@ -49,12 +49,14 @@ } .widgetIcon { + width: var(--mantine-font-size-sm); + height: var(--mantine-font-size-sm); flex-shrink: 0; color: var(--mantine-color-dimmed); } .name { - font-size: 11px; + font-size: var(--mantine-font-size-xs); font-weight: 600; line-height: 1.2; white-space: nowrap; @@ -94,7 +96,7 @@ padding: 2px 6px; border-radius: var(--mantine-radius-md); background-color: var(--chip-background); - font-size: 10px; + font-size: var(--mantine-font-size-xxs); line-height: 1.2; white-space: nowrap; overflow: hidden; diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css index a072d8c74..49f71bf7b 100644 --- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css +++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css @@ -2,7 +2,8 @@ width: 100%; max-width: 100%; min-width: 0; - overflow: clip; + overflow-x: clip; + overflow-y: visible; } .viewport[data-board-hydrated="false"] { diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index 53079451f..4b7e47054 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -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: 10; + z-index: calc(var(--homarr-z-index-board-header) - 1); } .board-grid-entry[data-grid-preview="true"] { diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx index 558ba0c69..bf86ca341 100644 --- a/apps/nextjs/src/components/layout/shell.tsx +++ b/apps/nextjs/src/components/layout/shell.tsx @@ -27,6 +27,12 @@ export const ClientShell = ({ return ( 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 diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index e8aae1b60..e98d9dd4c 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -2814,14 +2814,11 @@ export const boardLayoutsNeedRepair = ( ) => { const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile"); const baseLayouts = boardLayouts.filter((layout) => layout.role === "base"); - const baseLayout = baseLayouts.at(0); return ( mobileLayouts.length !== 1 || baseLayouts.length !== 1 || mobileLayouts.at(0)?.breakpoint !== 0 || - !baseLayout || - boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) || new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length ); }; diff --git a/packages/api/src/router/test/onboard.spec.ts b/packages/api/src/router/test/onboard.spec.ts index 4b765792a..5e31efef3 100644 --- a/packages/api/src/router/test/onboard.spec.ts +++ b/packages/api/src/router/test/onboard.spec.ts @@ -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 () => { diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 3e4cdebf2..e06769cda 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -143,8 +143,8 @@ export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: str } } - const mobileLayout = normalizedLayouts[0]; - const baseLayout = normalizedLayouts.at(-1); + const mobileLayout = normalizedLayouts.find((layout) => layout.role === "mobile"); + const baseLayout = normalizedLayouts.find((layout) => layout.role === "base"); if (mobileLayout && baseLayout) { await insertMissingProjectedPositionsAsync( db, diff --git a/packages/definitions/src/board.ts b/packages/definitions/src/board.ts index 3bd5ffdd9..625248a95 100644 --- a/packages/definitions/src/board.ts +++ b/packages/definitions/src/board.ts @@ -33,14 +33,14 @@ export const normalizeBoardLayoutRoles = < const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base"); const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined; const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined; - const orderedLayouts = - mobileLayout && baseLayout - ? [ - mobileLayout, - ...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts), - baseLayout, - ] - : sourceLayouts.toSorted(compareBoardLayouts); + const hasProtectedLayouts = mobileLayout !== undefined && baseLayout !== undefined; + let orderedLayouts = sourceLayouts.toSorted(compareBoardLayouts); + if (hasProtectedLayouts) { + orderedLayouts = [ + mobileLayout, + ...sourceLayouts.filter((layout) => layout.id !== mobileLayout.id).toSorted(compareBoardLayouts), + ]; + } let previousBreakpoint = -1; return orderedLayouts.map((layout, index) => { @@ -49,7 +49,12 @@ export const normalizeBoardLayoutRoles = < index === 0 ? 0 : Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint)); - const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom"; + let role: LayoutRole = layout.role ?? "custom"; + if (!hasProtectedLayouts) { + if (index === 0) role = "mobile"; + else if (index === orderedLayouts.length - 1) role = "base"; + else role = "custom"; + } previousBreakpoint = breakpoint; return { ...layout, breakpoint, role }; }); diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 662ad5e57..320c01c24 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -66,14 +66,20 @@ export const widgetDefaultSizes: Partial layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint)) { - ctx.addIssue({ code: "custom", message: "The Base layout must have the highest breakpoint" }); - } - if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) { ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" }); } diff --git a/packages/widgets/src/beszel-system-stats/component.module.css b/packages/widgets/src/beszel-system-stats/component.module.css index f9e9b584b..d41f57deb 100644 --- a/packages/widgets/src/beszel-system-stats/component.module.css +++ b/packages/widgets/src/beszel-system-stats/component.module.css @@ -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; } diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 0aca4348f..eb24774eb 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -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 ( - + + {!isEditMode && ( + + {systems.length > 1 && ( + + + + + + {systems.map((system) => ( + handleSelectSystem(system.value)} + > + {system.label} + + ))} + + + )} + {responsiveWidth >= 560 ? ( + + {periodOptions.map((period) => ( + + ))} + + ) : ( + value && handleTimePeriod(value)} - disabled={isSelectionSavePending} - data={periodOptions} - className={classes.beszelStatsPeriodToggle} - /> - )} - - )} + 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, diff --git a/packages/widgets/src/beszel/_shared/chart.tsx b/packages/widgets/src/beszel/_shared/chart.tsx index 7a9949b6d..89dacb535 100644 --- a/packages/widgets/src/beszel/_shared/chart.tsx +++ b/packages/widgets/src/beszel/_shared/chart.tsx @@ -101,12 +101,12 @@ interface BeszelChartPanelProps { export const BeszelChartPanel = memo(({ title, subtitle, chartProps }: BeszelChartPanelProps) => ( - - + + {title} {subtitle && ( - + {subtitle} )} diff --git a/packages/widgets/src/beszel/_shared/stats-view.tsx b/packages/widgets/src/beszel/_shared/stats-view.tsx index 66a24f38f..91996d266 100644 --- a/packages/widgets/src/beszel/_shared/stats-view.tsx +++ b/packages/widgets/src/beszel/_shared/stats-view.tsx @@ -35,13 +35,13 @@ const tooltips = { bytesTotal: makeTooltipProps(formatStorageBytes, true), }; -const ChartSkeleton = () => ( +const ChartSkeleton = ({ height }: { height: number }) => ( - + ); @@ -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 = (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 ( - + {Array.from({ length: visibleChartCount }, (_, index) => ( - + ))} ); @@ -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 ( - + {visibility.cpu && cpuData.length > 0 && ( + 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 = ( - + {date.getDate()} - + ); @@ -70,10 +86,11 @@ 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), ); @@ -82,7 +99,7 @@ const NotificationIndicator = ({ events, size, visible }: NotificationIndicatorP return ( { + const tSearch = useI18n("search.mode.media"); + + return ( + + + + + + + {title} + + + {description} + + + {!isEditMode ? ( + + ) : null} + + ); +}; diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index 617b332be..59d2e14be 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -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 ; if (!mediaRequestData) return ; const { requests: mediaRequests, failedIntegrations } = mediaRequestData; - if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError(); + if (mediaRequests.length === 0 && failedIntegrations.length === 0) { + return ( + + ); + } const showIntegrationSource = new Set(mediaRequests.map(({ integrationId }) => integrationId)).size > 1; return ( diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 92aaac4c6..074014264 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -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 ( + + ); + } const data = [ { diff --git a/packages/widgets/src/speedtest-tracker/component.tsx b/packages/widgets/src/speedtest-tracker/component.tsx index 8ccae86e4..efb2b4376 100644 --- a/packages/widgets/src/speedtest-tracker/component.tsx +++ b/packages/widgets/src/speedtest-tracker/component.tsx @@ -1,7 +1,7 @@ "use client"; -import { useMemo, useState } from "react"; -import { Box, Card, Group, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useMemo } from "react"; +import { Box, Group, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useI18n } from "@homarr/translation/client"; @@ -21,14 +21,10 @@ export default function SpeedtestTrackerWidget({ options, integrationIds, width, - height, - displayMode = "compact", }: WidgetComponentProps<"speedtestTracker">) { const t = useI18n("widget.speedtestTracker"); const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds }); const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData; - const [advancedRange, setAdvancedRange] = useState("24"); - const isAdvanced = displayMode === "advanced"; const successfulDashboards = useMemo(() => getAvailableSpeedtestDashboards(dashboardData), [dashboardData]); const combined = useMemo( @@ -36,98 +32,24 @@ export default function SpeedtestTrackerWidget({ [successfulDashboards], ); - const rangeHours = isAdvanced ? Number(advancedRange) : 12; - const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0; + const cutoff = Date.now() - 12 * 60 * 60 * 1000; const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff); - const showLatestResult = isAdvanced || options.showLatestResult; - const showStats = isAdvanced || options.showStats; - const showRecentResults = isAdvanced || options.showRecentResults; - const showPingGraph = isAdvanced || options.showPingGraph; + const showLatestResult = options.showLatestResult; + const showStats = options.showStats; + const showRecentResults = options.showRecentResults; + const showPingGraph = options.showPingGraph; const hasStatSection = (showLatestResult && combined.latestResult !== null) || (showStats && combined.stats !== null); const hasChart = showRecentResults && recentFiltered.length > 0; - const noSectionsEnabled = - !isAdvanced && !options.showLatestResult && !options.showStats && !options.showRecentResults; - const advancedSectionWidth = width >= 720 ? width / 2 : width; + const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults; const latest = showLatestResult && combined.latestResult && ( - + ); const averages = showStats && combined.stats && ( - + ); - if (displayMode === "advanced") { - const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1; - const sourcePanelWidth = sourceColumns === 2 ? Math.max(0, (width - 48) / 2) : Math.max(0, width - 32); - const sourceSectionWidth = sourcePanelWidth >= 720 ? sourcePanelWidth / 2 : sourcePanelWidth; - - return ( - - - - - - - - {successfulDashboards.length === 0 ? ( - - ) : ( - - {successfulDashboards.map(({ integrationId, integrationName, dashboard }) => { - const sourceRecent = dashboard.recentResults.filter((result) => result.created_at.getTime() > cutoff); - const sourceHasStats = dashboard.latestResult !== null || dashboard.stats !== null; - const sourceHasChart = sourceRecent.length > 0; - - return ( - - - - {integrationName} - - {sourceHasStats && ( - = 720 ? 2 : 1} spacing="md"> - {dashboard.latestResult && ( - - )} - {dashboard.stats && } - - )} - {sourceHasChart && ( - - - - )} - {!sourceHasStats && !sourceHasChart && } - - - ); - })} - - )} - - - - ); - } - return ( diff --git a/packages/widgets/src/speedtest-tracker/index.ts b/packages/widgets/src/speedtest-tracker/index.ts index dbc989d99..4ab6118f1 100644 --- a/packages/widgets/src/speedtest-tracker/index.ts +++ b/packages/widgets/src/speedtest-tracker/index.ts @@ -7,7 +7,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", { icon: IconSpeedboat, - supportsAdvancedFocus: true, refetchInterval: null, createOptions() { return optionsBuilder.from( diff --git a/packages/widgets/src/uptime-kuma/component.module.css b/packages/widgets/src/uptime-kuma/component.module.css index f56a12bda..57fb16aed 100644 --- a/packages/widgets/src/uptime-kuma/component.module.css +++ b/packages/widgets/src/uptime-kuma/component.module.css @@ -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; } diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index 908d8ed52..6108fec8f 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -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 - {summaryContent} + {summaryContent} {monitorList}