diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 67fa8119f..3165867d4 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -113,7 +113,7 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio - Set desktop and mobile home boards for the current user - Configure global board defaults such as public home boards and status behavior -Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation. +Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, and use a unique breakpoint for every layout. Base and custom layouts can appear in any breakpoint order. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation. The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum. diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 3fa95d2d4..5c3a20c9f 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. 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/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..0de7580b3 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: var(--homarr-z-index-widget-focus); } .board-grid-entry[data-grid-preview="true"] { diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index d2568b5f6..93526dc72 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -2776,14 +2776,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/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/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 4dc3b1783..5ef1cee93 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4672,12 +4672,6 @@ "noSectionsEnabled": "Enable at least one section in widget options.", "error": { "internalServerError": "Failed to fetch speedtest data" - }, - "range": { - "twelveHours": "12h", - "oneDay": "24h", - "all": "All", - "lastThirty": "Last 30" } }, "uptimeKuma": { @@ -5424,7 +5418,7 @@ "title": "Layout", "responsive": { "title": "Responsive layouts", - "description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts for widths in between.", + "description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts at any additional breakpoint.", "action": { "add": "Add layout" } diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index 97120bbba..2d6c982dd 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -116,10 +116,6 @@ export const responsiveBoardLayoutsSchema = z ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" }); } - if (layouts.some((layout) => 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/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 81ddd8f3f..b61f5488d 100644 --- a/packages/widgets/src/speedtest-tracker/index.ts +++ b/packages/widgets/src/speedtest-tracker/index.ts @@ -5,7 +5,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", { icon: IconSpeedboat, - supportsAdvancedFocus: true, refetchInterval: null, createOptions() { return optionsBuilder.from(