diff --git a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx index bc971bc4a..66a0f2f35 100644 --- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx +++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx @@ -6,21 +6,21 @@ tags: Homarr has the following keyboard shortcuts: -| Shortcut | Description | -| ---------------------------------------------- | ------------------------------------------ | -| `MOD` + `K` | Opens the search bar | -| `MOD` + `J` | Toggles Dark Mode | -| `MOD` + `E` | Toggles Edit Mode | -| Hold `Shift` while hovering a supported widget | Opens its temporary advanced view | -| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed | -| `Esc` | Closes an open advanced view or board menu | +| Shortcut | Description | +| ----------------------------------------------- | ------------------------------------------ | +| `MOD` + `K` | Opens the search bar | +| `MOD` + `J` | Toggles Dark Mode | +| `MOD` + `E` | Toggles Edit Mode | +| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view | +| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed | +| `Esc` | Closes an open advanced view or board menu | :::tip Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS. Advanced view is available outside edit mode. Widgets without a distinct advanced experience adapt to their normal -dimensions instead. +dimensions instead. A progress ring follows the pointer while the temporary advanced view is preparing to open. ::: diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 359d189e6..35e9f49e4 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -82,6 +82,8 @@ 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. + Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. ## Partial integration failures diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 83918e06b..b67da5635 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -38,7 +38,8 @@ Underscores and hyphen-minus are allowed. The column count defines how many fixed-width cells are available in each row. One cell contains a `200 × 200` logical-pixel tile. -Homarr scales the whole board uniformly instead of changing widget proportions, with horizontal scrolling when further shrinking would make controls too small. +Homarr scales the whole board uniformly instead of changing widget proportions, and always fits it to the available width without horizontal scrolling. +The main canvas and enabled rails stay at least one viewport tall, and the canvas grows automatically while you drag or resize items near its lower edge. ### Public @@ -89,37 +90,41 @@ Homarr will fall back to the Homarr logo if nothing is set. ### Layout -In this section you can configure the different responsive layouts. -Homarr selects a layout from the screen width, then scales its complete fixed-size canvas uniformly. +In this section you can configure the different responsive layouts. Homarr selects the layout with the highest breakpoint that is not wider than the current screen, then scales its complete fixed-size canvas uniformly. ![](./img/layout-settings.png) +Every board has two protected layouts: + Every `1 × 1` tile has a `200 × 200` logical-pixel content area. The `24` logical-pixel gap between tiles is separate from that size. A `2 × 1` tile therefore contains two fixed cell widths plus the gap between them. Widgets see their logical size, while the board's visual scale makes the complete layout larger or smaller. When fitting below 100%, Homarr keeps widget typography and control density at their normal readable size. On larger canvases, the complete board grows together like browser zoom. -The scale never drops below 75%; on narrower screens the canvas scrolls horizontally instead of shrinking controls below an accessible size. +The complete canvas always fits the available width and never adds horizontal board scrolling. -The position and size of every item are independent in each responsive layout. -Moving or resizing an item in one layout does not change any other layout. -When adding a new layout, its initial positions and sizes are generated from the column count. -New items use their widget's default grid size, while a new Container starts at a useful `4 × 3` size when the layout has enough columns. -Both are placed in the first available position. +- **Mobile** starts at `0px` and uses three columns on new boards. +- **Base** is the widest layout and starts at `768px` on new boards. -The current board edge is a hard drag boundary, so moving a tile outside the canvas cancels that placement instead of creating empty rows. -Adding content can still create the rows required for the new tile. +Mobile and Base can be renamed and their column counts and rails can be changed, but they cannot be removed. You can add removable custom layouts between their breakpoints. + +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 are generated from Base and scaled to their selected column count. + +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. + +The canvas is at least one window tall, even when it is empty. +Its left and right edges are hard drag boundaries, while its bottom edge extends automatically as a tile is dragged or resized downward. Nested Containers keep their own resizable bounds. +New items use their widget's default size and new Containers use a useful `4 × 3` size when space allows. #### Name -The name of the layout can be chosen freely. It is only used for management and will not be visible on the board. +The name of the layout can be chosen freely. It is only used for management and is not displayed on the board. #### Column count -Total number of columns for this layout. -When changed, Homarr moves items into the next available positions without overlap. +The total number of columns in the layout. Changing it projects the saved positions and sizes into the new grid width without overlap. #### Fixed rails @@ -133,13 +138,12 @@ For example, a 12-column layout with a two-column left rail leaves ten columns f Enabling or widening a rail reflows canvas items into the remaining columns. Disabling a rail moves its contents back into the next available canvas positions. -Rails remain visible while the page scrolls. -Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode. +Rails fill the available window height and remain fixed while the page scrolls. +Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode, where each rail shows its name and boundary. #### Breakpoint -Minimum width of the screen where the layout will be used. -Once the minimum of the next layout is reached, the layout will change. +The minimum screen width where the layout is used. Mobile is fixed at `0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint. #### View and edit modes @@ -164,6 +168,10 @@ The **Move / resize item** action in the edit menu remains available for precise #### Preview +Layout Settings shows a lightweight structural preview for every layout, including saved spans, Containers, widget labels, and app icons. The canvas width represents the screen range where that layout is active. + +Choose **Edit layout** to open the real board editor at that representative width on desktop. Save returns to Layout Settings. Cancel discards the local changes. On phones and tablets, the normal board editor continues to use the layout selected by the device width. + diff --git a/apps/docs/docs/widgets/docker-containers/index.mdx b/apps/docs/docs/widgets/docker-containers/index.mdx index 397520a7e..1959ca959 100644 --- a/apps/docs/docs/widgets/docker-containers/index.mdx +++ b/apps/docs/docs/widgets/docker-containers/index.mdx @@ -4,8 +4,6 @@ description: "Stats of your containers" hide_title: true --- -import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; - import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -44,8 +42,6 @@ import dockerTable from "./img/table.png"; Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode. -Advanced mode exposes every available column regardless of the compact-mode column selection. - Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options: - **View logs** - opens a dedicated terminal page streaming the container's logs @@ -53,8 +49,6 @@ Each container row has an action menu, accessible via the three-dot icon or by r - **Remove** - permanently remove the container after a second confirmation click - **Add to Homarr** - create a bookmark app from the container - - ### Adding the widget diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css index 158ad16ae..8514e86d9 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css @@ -9,14 +9,48 @@ } .gutter { - position: relative; - top: 0; + --board-gutter-sticky-top: calc((var(--app-shell-header-offset, 0px) + 1rem) * var(--board-canvas-inverse-scale, 1)); + --board-gutter-viewport-height: calc( + (100dvh - var(--app-shell-header-offset, 0px) - 1rem) * var(--board-canvas-inverse-scale, 1) + ); + + position: sticky; + top: var(--board-gutter-sticky-top); align-self: start; + box-sizing: border-box; + height: var(--board-gutter-viewport-height); padding-top: 24px; margin-top: -24px; - max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md)); + overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; - transform: translateY(var(--board-gutter-sticky-offset, 0px)); +} + +.gutter[data-board-editing="true"] { + outline: 1px dashed var(--mantine-color-default-border); + outline-offset: -1px; + border-radius: var(--mantine-radius-md); +} + +.gutter[data-board-editing="true"]::before { + position: sticky; + top: 0; + inset-inline-start: 0; + z-index: 2; + display: block; + width: fit-content; + height: 24px; + padding-inline: var(--mantine-spacing-xs); + margin-top: -24px; + overflow: hidden; + font-size: var(--mantine-font-size-xs); + line-height: 24px; + color: var(--mantine-color-dimmed); + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + content: attr(aria-label); + background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent); + border-radius: var(--mantine-radius-sm); } diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index e77a9fa6b..7c3276d9b 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -1,11 +1,12 @@ "use client"; -import { useEffect, useState } from "react"; -import { Box } from "@mantine/core"; +import { Box, Paper, Stack, Text } from "@mantine/core"; -import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context"; +import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context"; +import { useEditMode } from "@homarr/boards/edit-mode"; import { useScopedI18n } from "@homarr/translation/client"; +import { getRepresentativeLayoutWidth } from "../_layout-utils"; import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; import { getBoardLaneColumnCount, @@ -24,66 +25,14 @@ import classes from "./_client.module.css"; const APP_SHELL_INLINE_PADDING = 32; -const useFixedBoardGutters = () => { - const [columns, setColumns] = useState(null); - - useEffect(() => { - if (!columns) return; - - let frame = 0; - const update = () => { - frame = 0; - const columnsRect = columns.getBoundingClientRect(); - const headerBottom = document.querySelector("[data-app-shell-header]")?.getBoundingClientRect().bottom ?? 0; - const stickyTop = Math.max(0, headerBottom + 16); - - for (const gutter of columns.querySelectorAll("[data-board-gutter]")) { - const rect = gutter.getBoundingClientRect(); - if (gutter.offsetWidth <= 0) continue; - - const effectiveScale = rect.width / gutter.offsetWidth; - if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) continue; - - const previousOffset = Number(gutter.dataset.stickyOffset ?? 0); - const uncorrectedTop = rect.top - previousOffset * effectiveScale; - const maximumTop = columnsRect.bottom - rect.height; - const targetTop = Math.min(stickyTop, maximumTop); - const nextOffset = Math.max(0, targetTop - uncorrectedTop) / effectiveScale; - - gutter.dataset.stickyOffset = String(nextOffset); - gutter.style.setProperty("--board-gutter-sticky-offset", `${nextOffset}px`); - } - }; - const scheduleUpdate = () => { - if (frame !== 0) return; - frame = window.requestAnimationFrame(update); - }; - - const resizeObserver = new ResizeObserver(scheduleUpdate); - resizeObserver.observe(columns); - document.addEventListener("scroll", scheduleUpdate, { capture: true, passive: true }); - window.addEventListener("scroll", scheduleUpdate, { passive: true }); - window.addEventListener("resize", scheduleUpdate, { passive: true }); - scheduleUpdate(); - - return () => { - window.cancelAnimationFrame(frame); - resizeObserver.disconnect(); - document.removeEventListener("scroll", scheduleUpdate, { capture: true }); - window.removeEventListener("scroll", scheduleUpdate); - window.removeEventListener("resize", scheduleUpdate); - }; - }, [columns]); - - return setColumns; -}; - export const ClientBoard = () => { const board = useRequiredBoard(); const t = useScopedI18n("board.landmark"); + const tPreview = useScopedI18n("board.setting.section.layout.preview"); + const [isEditMode] = useEditMode(); const currentLayoutId = useCurrentLayout(); const initialViewportWidth = useInitialViewportWidth(); - const columnsRef = useFixedBoardGutters(); + const layoutOverrideId = useLayoutOverride(); const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0); if (!currentLayout) throw new Error("Expected the board to contain a layout"); @@ -101,7 +50,8 @@ export const ClientBoard = () => { const logicalWidth = laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP; const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId) + LOGICAL_GRID_GAP; - const initialAvailableWidth = Math.max(1, initialViewportWidth - APP_SHELL_INLINE_PADDING); + const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null; + const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING); const gridTemplateColumns = [ leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null, `${getLogicalTrackSize(mainColumnCount)}px`, @@ -110,7 +60,7 @@ export const ClientBoard = () => { .filter((value) => value !== null) .join(" "); - return ( + const content = ( @@ -124,16 +74,13 @@ export const ClientBoard = () => { > -
+
{leftColumnCount > 0 && leftSection && (