diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx
index c89d7fb68..ef408e221 100644
--- a/apps/docs/docs/management/boards/index.mdx
+++ b/apps/docs/docs/management/boards/index.mdx
@@ -90,11 +90,12 @@ 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.

+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.
@@ -103,28 +104,31 @@ When fitting below 100%, Homarr keeps widget typography and control density at t
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.
-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.
+
+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.
+
+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
-Each responsive layout can enable a fixed left rail, a fixed right rail, or both.
+Base and custom responsive layouts can enable a fixed left rail, a fixed right rail, or both. Mobile has no fixed rails.
Set a rail width to zero to disable it for that layout.
Choose a width from one to three columns with the three-step slider.
The preview shows how much space remains for the main canvas.
@@ -139,8 +143,7 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
#### 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
@@ -165,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/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
index 026c6d5c7..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 { 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,
@@ -27,9 +28,11 @@ const APP_SHELL_INLINE_PADDING = 32;
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 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");
@@ -47,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`,
@@ -56,7 +60,7 @@ export const ClientBoard = () => {
.filter((value) => value !== null)
.join(" ");
- return (
+ const content = (
@@ -121,4 +125,24 @@ export const ClientBoard = () => {
);
+
+ if (representativeWidth === null) return content;
+
+ return (
+
+
+ {tPreview("editorWidthLabel", { layoutName: currentLayout.name, width: representativeWidth })}
+
+
+ {content}
+
+
+ );
};
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
index 934f81d26..cf50c0c69 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
@@ -2,10 +2,18 @@
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
-import { IconLayoutBoard, IconPencil, IconPencilOff, IconReplace, IconSettings } from "@tabler/icons-react";
+import {
+ IconDeviceFloppy,
+ IconLayoutBoard,
+ IconPencil,
+ IconPencilOff,
+ IconReplace,
+ IconSettings,
+ IconX,
+} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -76,11 +84,19 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
const board = useRequiredBoard();
const utils = clientApi.useUtils();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { openConfirmModal } = useConfirmModal();
+ const commonT = useI18n();
const t = useScopedI18n("board.action.edit");
+ const returnToLayoutSettings =
+ isEditMode &&
+ searchParams.get("edit") === "true" &&
+ searchParams.get("layout") !== null &&
+ searchParams.get("returnTo") === "settings";
const latestBoardRef = useRef(board);
latestBoardRef.current = board;
-
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onSuccess() {
showSuccessNotification({
@@ -91,6 +107,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync(`/boards/${board.name}`);
close();
+ if (returnToLayoutSettings) router.push(`/boards/${board.name}/settings?tab=layout`);
},
onError() {
showErrorNotification({
@@ -155,9 +172,45 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
}
}, [demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]);
+ const cancelLayoutEdit = useCallback(() => {
+ openConfirmModal({
+ title: commonT("board.setting.section.layout.edit.cancel.title"),
+ children: commonT("board.setting.section.layout.edit.cancel.message"),
+ confirmProps: { children: commonT("common.action.discard"), color: "red" },
+ onConfirm() {
+ void (async () => {
+ await utils.board.getBoardByName.invalidate({ name: board.name });
+ close();
+ router.push(`/boards/${board.name}/settings?tab=layout`);
+ })();
+ },
+ });
+ }, [board.name, close, commonT, openConfirmModal, router, utils.board.getBoardByName]);
+
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
usePreventLeaveWithDirty(isEditMode);
+ if (returnToLayoutSettings) {
+ return (
+ <>
+ saveBoard(board)}
+ loading={isPending}
+ aria-label={commonT("board.setting.section.layout.edit.save")}
+ >
+
+
+
+
+
+ >
+ );
+ }
+
return (
;
+ isSaving: boolean;
+ saveSettingsAsync: () => Promise;
}
-export const LayoutSettingsContent = ({ form }: Props) => {
+export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const t = useI18n();
+ const router = useRouter();
+ const utils = clientApi.useUtils();
+ const { openConfirmModal } = useConfirmModal();
+ const [editingLayoutId, setEditingLayoutId] = useState(null);
+ const [resettingLayoutId, setResettingLayoutId] = useState(null);
+ const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({
+ onError() {
+ showErrorNotification({
+ title: t("common.notification.update.error"),
+ message: t("common.notification.update.error"),
+ });
+ },
+ });
+ const appIds = useMemo(
+ () =>
+ Array.from(
+ new Set(
+ board.items.flatMap((item) =>
+ item.kind === "app" && typeof item.options.appId === "string" ? [item.options.appId] : [],
+ ),
+ ),
+ ),
+ [board.items],
+ );
+ const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 });
+
+ const openLayoutEditorAsync = async (layout: FormValues["layouts"][number]) => {
+ setEditingLayoutId(layout.id);
+ try {
+ const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
+ const savedValues = form.isDirty() || !persistedLayout ? await saveSettingsAsync() : form.values;
+ const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined;
+ if (!canonicalLayout) return;
+
+ router.push(`/boards/${board.name}?layout=${encodeURIComponent(canonicalLayout.id)}&edit=true&returnTo=settings`);
+ } finally {
+ setEditingLayoutId(null);
+ }
+ };
+
+ const confirmReset = (layout: FormValues["layouts"][number]) => {
+ openConfirmModal({
+ title: t("board.setting.section.layout.reset.confirm.title", { layoutName: layout.name }),
+ children: t("board.setting.section.layout.reset.confirm.message"),
+ confirmProps: { children: t("board.setting.section.layout.reset.action"), color: "red" },
+ onConfirm() {
+ setResettingLayoutId(layout.id);
+ void (async () => {
+ try {
+ const savedValues = form.isDirty() ? await saveSettingsAsync() : form.values;
+ const canonicalLayout = savedValues ? findCanonicalLayout(savedValues.layouts, layout) : undefined;
+ if (!canonicalLayout) return;
+
+ await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id });
+ await utils.board.getBoardByName.invalidate({ name: board.name });
+ router.refresh();
+ } catch {
+ // The mutation callback displays the error notification.
+ } finally {
+ setResettingLayoutId(null);
+ }
+ })();
+ },
+ });
+ };
+
+ const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
+ const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
+ const sortedLayouts = form.values.layouts
+ .map((layout, index) => ({ layout, index }))
+ .toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
return (
-
-
- {t("board.setting.section.layout.responsive.title")}
+
+
+
+ {t("board.setting.section.layout.responsive.title")}
+ {t("board.setting.section.layout.responsive.description")}
+
- {form.values.layouts.map((layout, index) => (
-
+ }
+ bg="transparent"
+ >
+
+
+
+
+
+ ({ value, label: String(value) }))}
+ styles={{
+ markLabel: { color: "light-dark(var(--mantine-color-black), var(--mantine-color-white))" },
+ }}
+ value={layout.columnCount}
+ onChange={(columnCount) => {
+ const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
+ const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
+ form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
+ form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
+ form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
+ }}
+ />
+
+ breakpoint + 1))
+ : 1
+ }
+ max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
+ />
+
+
+
+ {sourceLayout && (
+
+ )}
+
+ {layout.role !== "mobile" && (
+
+ form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
+ onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
+ />
+
+ )}
+
-
-
-
-
- form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
- onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
- />
-
-
- {form.values.layouts.length >= 2 && (
-
+
+
+ {layout.role !== "base" && persistedLayout && (
+ }
+ loading={resettingLayoutId === layout.id}
+ disabled={isSaving}
+ onClick={() => confirmReset(layout)}
+ >
+ {t("board.setting.section.layout.reset.action")}
+
+ )}
+ {layout.role === "custom" && (
+
+ )}
+
- )}
-
- ))}
+
+ );
+ })}
);
};
+const findCanonicalLayout = (layouts: FormValues["layouts"], layout: FormValues["layouts"][number]) =>
+ layouts.find((candidate) => candidate.id === layout.id) ??
+ layouts.find((candidate) => candidate.breakpoint === layout.breakpoint && candidate.role === layout.role);
+
+const getNextCustomBreakpoint = (layouts: Array<{ breakpoint: number }>) => {
+ const sortedBreakpoints = layouts
+ .map((layout) => layout.breakpoint)
+ .toSorted((breakpointA, breakpointB) => breakpointA - breakpointB);
+ let largestGap: { start: number; end: number } | null = null;
+
+ for (let index = 0; index < sortedBreakpoints.length - 1; index++) {
+ const start = sortedBreakpoints[index];
+ const end = sortedBreakpoints[index + 1];
+ if (start === undefined || end === undefined || end - start <= 1) continue;
+ if (!largestGap || end - start > largestGap.end - largestGap.start) largestGap = { start, end };
+ }
+
+ return largestGap ? Math.floor((largestGap.start + largestGap.end) / 2) : null;
+};
+
interface GutterSettingsProps {
left: number;
right: number;
@@ -158,7 +338,7 @@ const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange
-
+
-
+
onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
label={t(`layout.field.gutters.${side}.label`)}
- description={t(`layout.field.gutters.${side}.description`)}
/>
{enabled && (
diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.module.css b/apps/nextjs/src/components/board/mobile/mobile-board.module.css
new file mode 100644
index 000000000..f598ba348
--- /dev/null
+++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css
@@ -0,0 +1,20 @@
+.grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-auto-flow: row;
+ grid-auto-rows: min(calc((100vw - 42px) / 2), 12rem);
+ gap: 10px;
+ padding-bottom: 10px;
+}
+
+.item {
+ position: relative;
+ min-width: 0;
+ min-height: 0;
+}
+
+.item > :global(.grid-stack-item-content) {
+ width: 100%;
+ height: 100%;
+ overflow-y: auto;
+}
diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx
new file mode 100644
index 000000000..1568b6aac
--- /dev/null
+++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import type { RefObject } from "react";
+import { useMemo, useRef } from "react";
+import { Box } from "@mantine/core";
+
+import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
+import { ReadOnlyEditModeProvider } from "@homarr/boards/edit-mode";
+import type { GridItemHTMLElement, GridStack as GridStackInstance } from "@homarr/gridstack";
+
+import { BoardItemContent } from "../items/item-content";
+import { SectionProvider } from "../sections/section-context";
+import classes from "./mobile-board.module.css";
+import { createMobileBoardItems } from "./mobile-layout";
+
+export const MobileBoard = () => {
+ const board = useRequiredBoard();
+ const desktopLayout = getDesktopLayout(board);
+ const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]);
+ const rootSection = board.sections.find((section) => section.kind !== "dynamic");
+ const wrapperRef = useRef(null);
+ const itemRefs = useRef>>({});
+ const gridstackRef = useRef(null);
+
+ if (!rootSection) return null;
+
+ return (
+
+
+
+ {items.map((item) => (
+
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts
new file mode 100644
index 000000000..73979b6bb
--- /dev/null
+++ b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts
@@ -0,0 +1,85 @@
+import { describe, expect, test } from "vitest";
+
+import { CategorySectionMockBuilder } from "../items/actions/test/mocks/category-section-mock";
+import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock";
+import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock";
+import { EmptySectionMockBuilder } from "../items/actions/test/mocks/empty-section-mock";
+import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock";
+import { createMobileBoardItems } from "./mobile-layout";
+
+describe("createMobileBoardItems", () => {
+ test("flattens categories and dynamic sections in visual order", () => {
+ const board = new BoardMockBuilder().build();
+ const desktopLayoutId = board.layouts.at(0)?.id;
+ if (!desktopLayoutId) throw new Error("Expected a desktop layout");
+ const rootSection = new EmptySectionMockBuilder({ id: "root", yOffset: 0 }).build();
+ const category = new CategorySectionMockBuilder({ id: "category", yOffset: 1, collapsed: true }).build();
+ const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
+ .addLayout({ layoutId: desktopLayoutId, parentSectionId: rootSection.id, xOffset: 0, yOffset: 1 })
+ .build();
+ const nestedDynamicSection = new DynamicSectionMockBuilder({ id: "nested-dynamic" })
+ .addLayout({ layoutId: desktopLayoutId, parentSectionId: dynamicSection.id, xOffset: 0, yOffset: 1 })
+ .build();
+
+ board.sections.push(rootSection, category, dynamicSection, nestedDynamicSection);
+ board.items.push(
+ new ItemMockBuilder({ id: "second" })
+ .addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, xOffset: 1, yOffset: 0 })
+ .build(),
+ new ItemMockBuilder({ id: "first" })
+ .addLayout({
+ layoutId: desktopLayoutId,
+ sectionId: rootSection.id,
+ xOffset: 0,
+ yOffset: 0,
+ width: 8,
+ height: 6,
+ })
+ .build(),
+ new ItemMockBuilder({ id: "inside-dynamic" })
+ .addLayout({ layoutId: desktopLayoutId, sectionId: dynamicSection.id })
+ .build(),
+ new ItemMockBuilder({ id: "inside-nested-dynamic" })
+ .addLayout({ layoutId: desktopLayoutId, sectionId: nestedDynamicSection.id })
+ .build(),
+ new ItemMockBuilder({ id: "inside-collapsed-category" })
+ .addLayout({ layoutId: desktopLayoutId, sectionId: category.id })
+ .build(),
+ );
+
+ const result = createMobileBoardItems(board, desktopLayoutId);
+
+ expect(result.map((item) => item.id)).toEqual([
+ "first",
+ "second",
+ "inside-dynamic",
+ "inside-nested-dynamic",
+ "inside-collapsed-category",
+ ]);
+ expect(result[0]).toMatchObject({ width: 2, height: 3, xOffset: 0, yOffset: 0 });
+ });
+
+ test("uses the desktop layout and keeps orphaned items", () => {
+ const board = new BoardMockBuilder().build();
+ const legacyLayoutId = board.layouts.at(0)?.id;
+ if (!legacyLayoutId) throw new Error("Expected a legacy layout");
+ const desktopLayoutId = "desktop";
+ board.layouts.push({ id: desktopLayoutId, name: "Large", columnCount: 16, breakpoint: 1200 });
+ board.sections.push(new EmptySectionMockBuilder({ id: "root" }).build());
+ board.items.push(
+ new ItemMockBuilder({ id: "first" })
+ .addLayout({ layoutId: legacyLayoutId, sectionId: "root", yOffset: 2 })
+ .addLayout({ layoutId: desktopLayoutId, sectionId: "missing-section", yOffset: 0, width: 0, height: 0 })
+ .build(),
+ new ItemMockBuilder({ id: "second" })
+ .addLayout({ layoutId: legacyLayoutId, sectionId: "root", yOffset: 0 })
+ .addLayout({ layoutId: desktopLayoutId, sectionId: "root", yOffset: 1 })
+ .build(),
+ );
+
+ const result = createMobileBoardItems(board, desktopLayoutId);
+
+ expect(result.map((item) => item.id)).toEqual(["second", "first"]);
+ expect(result[1]).toMatchObject({ width: 1, height: 1 });
+ });
+});
diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.ts
new file mode 100644
index 000000000..7b23c6754
--- /dev/null
+++ b/apps/nextjs/src/components/board/mobile/mobile-layout.ts
@@ -0,0 +1,106 @@
+import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types";
+
+const mobileColumnCount = 2;
+const mobileMaxHeight = 3;
+
+type PositionedElement = DynamicSectionItem | SectionItem;
+type PositionedLayout = {
+ layoutId: string;
+ xOffset: number;
+ yOffset: number;
+ width: number;
+ height: number;
+};
+
+const comparePosition = (elementA: PositionedElement, elementB: PositionedElement) =>
+ elementA.yOffset - elementB.yOffset || elementA.xOffset - elementB.xOffset;
+
+const getLayout = (
+ layouts: TLayout[],
+ desktopLayoutId: string,
+ layoutPriority: Map,
+) =>
+ layouts.find((layout) => layout.layoutId === desktopLayoutId) ??
+ layouts.toSorted(
+ (layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1),
+ )[0];
+
+export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => {
+ const rootSections = board.sections
+ .filter((section) => section.kind !== "dynamic")
+ .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset || sectionA.xOffset - sectionB.xOffset);
+ const defaultSectionId = rootSections[0]?.id ?? "mobile";
+ const layoutPriority = new Map(
+ board.layouts
+ .toSorted(
+ (layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint || layoutA.columnCount - layoutB.columnCount,
+ )
+ .map((layout, index) => [layout.id, index]),
+ );
+
+ const items = board.items.map(({ layouts, ...item }, index): SectionItem => {
+ const layout =
+ getLayout(layouts, desktopLayoutId, layoutPriority) ??
+ ({
+ layoutId: desktopLayoutId,
+ xOffset: 0,
+ yOffset: index,
+ width: 1,
+ height: 1,
+ sectionId: defaultSectionId,
+ } satisfies ItemLayout);
+
+ return { ...item, ...layout, type: "item" };
+ });
+
+ const dynamicSections = board.sections
+ .filter((section) => section.kind === "dynamic")
+ .map(({ layouts, ...section }): DynamicSectionItem | null => {
+ const layout = getLayout(layouts, desktopLayoutId, layoutPriority);
+ return layout ? { ...section, ...layout, type: "section" } : null;
+ })
+ .filter((section) => section !== null);
+
+ const elementsBySection = new Map();
+ for (const element of [...items, ...dynamicSections]) {
+ const parentSectionId = element.type === "item" ? element.sectionId : element.parentSectionId;
+ const elements = elementsBySection.get(parentSectionId) ?? [];
+ elements.push(element);
+ elementsBySection.set(parentSectionId, elements);
+ }
+
+ const result: SectionItem[] = [];
+ const visitedItems = new Set();
+ const visitedSections = new Set();
+
+ const appendSectionItems = (sectionId: string) => {
+ if (visitedSections.has(sectionId)) return;
+ visitedSections.add(sectionId);
+
+ for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) {
+ if (element.type === "section") {
+ appendSectionItems(element.id);
+ continue;
+ }
+
+ if (!visitedItems.has(element.id)) {
+ visitedItems.add(element.id);
+ result.push(element);
+ }
+ }
+ };
+
+ rootSections.forEach((section) => appendSectionItems(section.id));
+ dynamicSections.toSorted(comparePosition).forEach((section) => appendSectionItems(section.id));
+ items.toSorted(comparePosition).forEach((item) => {
+ if (!visitedItems.has(item.id)) result.push(item);
+ });
+
+ return result.map((item, index) => ({
+ ...item,
+ xOffset: 0,
+ yOffset: index,
+ width: Math.max(1, Math.min(item.width, mobileColumnCount)),
+ height: Math.max(1, Math.min(item.height, mobileMaxHeight)),
+ }));
+};
diff --git a/apps/nextjs/src/components/board/use-mobile-board.ts b/apps/nextjs/src/components/board/use-mobile-board.ts
new file mode 100644
index 000000000..bc90d8bf2
--- /dev/null
+++ b/apps/nextjs/src/components/board/use-mobile-board.ts
@@ -0,0 +1,7 @@
+"use client";
+
+import { useMediaQuery } from "@mantine/hooks";
+
+export const mobileBoardMediaQuery = "(max-width: 48em)";
+
+export const useIsMobileBoard = () => useMediaQuery(mobileBoardMediaQuery);
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index 3053da760..9795dc937 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -2,13 +2,15 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod/v4";
-import { createLogger } from "@homarr/core/infrastructure/logs";
import { constructBoardPermissions } from "@homarr/auth/shared";
-import { createId } from "@homarr/common";
+import { createId, generateResponsiveGridFor } from "@homarr/common";
+import type { GridAlgorithmItem } from "@homarr/common";
import type { DeviceType } from "@homarr/common/server";
+import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
+import { seedProtectedBoardLayoutsAsync } from "@homarr/db/migrations/seed";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
boardGroupPermissions,
@@ -38,6 +40,7 @@ import {
getRootSectionLane,
getPermissionsWithChildren,
getPermissionsWithParents,
+ normalizeBoardLayoutRoles,
rootSectionOffsets,
widgetDefaultSizes,
widgetKinds,
@@ -52,6 +55,7 @@ import {
boardCreateSchema,
boardDuplicateSchema,
boardRenameSchema,
+ boardResetLayoutSchema,
boardSaveLayoutsSchema,
boardSavePartialSettingsSchema,
boardSavePermissionsSchema,
@@ -69,7 +73,6 @@ import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
} from "./board/custom-widget-placement-access";
-import { generateResponsiveGridFor } from "./board/grid-algorithm";
import { validateTimetableOptionsChangeAsync } from "./widgets/timetable";
export const boardRouter = createTRPCRouter({
@@ -322,15 +325,28 @@ export const boardRouter = createTRPCRouter({
yOffset: 0,
boardId,
});
- createBoardCollection.layouts.push({
- id: createId(),
- name: "Base",
- columnCount: input.columnCount,
- leftGutterColumnCount: 0,
- rightGutterColumnCount: 0,
- breakpoint: 0,
- boardId,
- });
+ createBoardCollection.layouts.push(
+ {
+ id: createId(),
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 0,
+ role: "mobile",
+ boardId,
+ },
+ {
+ id: createId(),
+ name: "Base",
+ columnCount: input.columnCount,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 768,
+ role: "base",
+ boardId,
+ },
+ );
await createBoardCollection.insertAllAsync(ctx.db);
@@ -387,8 +403,65 @@ export const boardRouter = createTRPCRouter({
const newBoardId = createId();
- const layoutsMap = new Map(boardLayouts.map((layout) => [layout.id, createId()]));
- const layoutsToInsert = boardLayouts.map((layout) => ({
+ const generatedMobileLayoutId = createId();
+ const generatedMobilePositions =
+ boardLayouts.length === 1 && boardLayouts[0]
+ ? getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: boardLayouts[0].id,
+ columnCount: boardLayouts[0].columnCount,
+ leftGutterColumnCount: boardLayouts[0].leftGutterColumnCount,
+ rightGutterColumnCount: boardLayouts[0].rightGutterColumnCount,
+ },
+ current: {
+ layoutId: generatedMobileLayoutId,
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ },
+ })
+ : null;
+ const normalizedBoardLayouts =
+ boardLayouts.length === 0
+ ? [
+ {
+ id: generatedMobileLayoutId,
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 0,
+ role: "mobile" as const,
+ boardId: board.id,
+ },
+ {
+ id: createId(),
+ name: "Base",
+ columnCount: 10,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 768,
+ role: "base" as const,
+ boardId: board.id,
+ },
+ ]
+ : boardLayouts.length === 1 && boardLayouts[0]
+ ? [
+ {
+ id: generatedMobileLayoutId,
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 0,
+ role: "mobile" as const,
+ boardId: board.id,
+ },
+ { ...boardLayouts[0], breakpoint: 768, role: "base" as const },
+ ]
+ : normalizeBoardLayoutRoles(boardLayouts);
+ const layoutsMap = new Map(normalizedBoardLayouts.map((layout) => [layout.id, createId()]));
+ const layoutsToInsert = normalizedBoardLayouts.map((layout) => ({
...layout,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: layoutsMap.get(layout.id)!,
@@ -405,9 +478,24 @@ export const boardRouter = createTRPCRouter({
}),
);
- const sectionLayoutsToInsert: InferInsertModel[] = boardSections.flatMap((section) =>
- section.layouts.map(
- (layoutSection): InferInsertModel => ({
+ const sectionLayoutsToInsert: InferInsertModel[] = boardSections
+ .flatMap((section) =>
+ section.layouts.map(
+ (layoutSection): InferInsertModel => ({
+ ...layoutSection,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ layoutId: layoutsMap.get(layoutSection.layoutId)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionId: sectionMap.get(layoutSection.sectionId)!,
+ parentSectionId: layoutSection.parentSectionId
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionMap.get(layoutSection.parentSectionId)!
+ : layoutSection.parentSectionId,
+ }),
+ ),
+ )
+ .concat(
+ (generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
@@ -416,10 +504,9 @@ export const boardRouter = createTRPCRouter({
parentSectionId: layoutSection.parentSectionId
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionMap.get(layoutSection.parentSectionId)!
- : layoutSection.parentSectionId,
- }),
- ),
- );
+ : null,
+ })),
+ );
const sectionCollapseStatesToInsert: InferInsertModel[] = boardSections.flatMap(
(section) =>
section.collapseStates.map(
@@ -441,9 +528,22 @@ export const boardRouter = createTRPCRouter({
}),
);
- const itemLayoutsToInsert: InferInsertModel[] = boardItems.flatMap((item) =>
- item.layouts.map(
- (layoutSection): InferInsertModel => ({
+ const itemLayoutsToInsert: InferInsertModel[] = boardItems
+ .flatMap((item) =>
+ item.layouts.map(
+ (layoutSection): InferInsertModel => ({
+ ...layoutSection,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionId: sectionMap.get(layoutSection.sectionId)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ itemId: itemMap.get(layoutSection.itemId)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ layoutId: layoutsMap.get(layoutSection.layoutId)!,
+ }),
+ ),
+ )
+ .concat(
+ (generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
@@ -451,9 +551,8 @@ export const boardRouter = createTRPCRouter({
itemId: itemMap.get(layoutSection.itemId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
- }),
- ),
- );
+ })),
+ );
// Creates a list with all integration ids the user has access to
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
@@ -694,153 +793,338 @@ export const boardRouter = createTRPCRouter({
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
- saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => {
- await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
+ saveLayouts: protectedProcedure
+ .input(boardSaveLayoutsSchema)
+ .output(boardSaveLayoutsSchema.shape.layouts)
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
- const requiredGutterRoots = (["left", "right"] as const).filter((lane) =>
- input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0),
- );
- await ensureGutterRootSectionsAsync(ctx.db, input.id, requiredGutterRoots);
+ const requiredGutterRoots = (["left", "right"] as const).filter((lane) =>
+ input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0),
+ );
+ await ensureGutterRootSectionsAsync(ctx.db, input.id, requiredGutterRoots);
- const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
+ const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
+ const existingLayoutsById = new Map(board.layouts.map((layout) => [layout.id, layout]));
+ const addedLayouts = filterAddedItems(input.layouts, board.layouts);
+ const removedLayouts = filterRemovedItems(input.layouts, board.layouts);
- const addedLayouts = filterAddedItems(input.layouts, board.layouts);
+ if (addedLayouts.some((layout) => layout.role !== "custom")) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "New layouts must use the custom role" });
+ }
+ if (removedLayouts.some((layout) => layout.role !== "custom")) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Mobile and Base layouts cannot be removed" });
+ }
+ if (
+ input.layouts.some((layout) => {
+ const existingLayout = existingLayoutsById.get(layout.id);
+ return existingLayout && existingLayout.role !== layout.role;
+ })
+ ) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Layout roles cannot be changed" });
+ }
- const layoutsToInsert: InferInsertModel[] = [];
- const itemSectionLayoutsToInsert: InferInsertModel[] = [];
- const sectionLayoutsToInsert: InferInsertModel[] = [];
+ const baseLayout = board.layouts.find((layout) => layout.role === "base");
+ if (!baseLayout) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a Base layout" });
+ }
- for (const addedLayout of addedLayouts) {
- const layoutId = createId();
+ const layoutsToInsert: InferInsertModel[] = [];
+ const itemSectionLayoutsToInsert: InferInsertModel[] = [];
+ const sectionLayoutsToInsert: InferInsertModel[] = [];
+ const savedLayoutIds = new Map();
+ const requestedBaseLayout = input.layouts.find((layout) => layout.id === baseLayout.id);
+ const resizedBaseLayout =
+ requestedBaseLayout &&
+ (requestedBaseLayout.columnCount !== baseLayout.columnCount ||
+ requestedBaseLayout.leftGutterColumnCount !== baseLayout.leftGutterColumnCount ||
+ requestedBaseLayout.rightGutterColumnCount !== baseLayout.rightGutterColumnCount)
+ ? getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: baseLayout.id,
+ columnCount: baseLayout.columnCount,
+ leftGutterColumnCount: baseLayout.leftGutterColumnCount,
+ rightGutterColumnCount: baseLayout.rightGutterColumnCount,
+ },
+ current: {
+ layoutId: baseLayout.id,
+ columnCount: requestedBaseLayout.columnCount,
+ leftGutterColumnCount: requestedBaseLayout.leftGutterColumnCount,
+ rightGutterColumnCount: requestedBaseLayout.rightGutterColumnCount,
+ },
+ })
+ : null;
+ const baseSourceElements = resizedBaseLayout ? getElementsForProjectedLayout(resizedBaseLayout) : undefined;
+ const baseSourceGeometry = {
+ layoutId: baseLayout.id,
+ columnCount: requestedBaseLayout?.columnCount ?? baseLayout.columnCount,
+ leftGutterColumnCount: requestedBaseLayout?.leftGutterColumnCount ?? baseLayout.leftGutterColumnCount,
+ rightGutterColumnCount: requestedBaseLayout?.rightGutterColumnCount ?? baseLayout.rightGutterColumnCount,
+ };
- const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
- // Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
- const layoutToClone =
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- sortedLayouts.find((layout) => layout.columnCount >= addedLayout.columnCount) ?? sortedLayouts.at(-1)!;
-
- const updatedBoardLayout = getUpdatedBoardLayout(board, {
- previous: {
- layoutId: layoutToClone.id,
- columnCount: layoutToClone.columnCount,
- leftGutterColumnCount: layoutToClone.leftGutterColumnCount,
- rightGutterColumnCount: layoutToClone.rightGutterColumnCount,
- },
- current: {
- layoutId,
+ for (const addedLayout of addedLayouts) {
+ const layoutId = createId();
+ savedLayoutIds.set(addedLayout.id, layoutId);
+ layoutsToInsert.push({
+ id: layoutId,
+ name: addedLayout.name,
columnCount: addedLayout.columnCount,
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
+ breakpoint: addedLayout.breakpoint,
+ role: "custom",
+ boardId: board.id,
+ });
+
+ const projectedLayout = getUpdatedBoardLayout(board, {
+ previous: {
+ ...baseSourceGeometry,
+ elements: baseSourceElements,
+ },
+ current: {
+ layoutId,
+ columnCount: addedLayout.columnCount,
+ leftGutterColumnCount: addedLayout.leftGutterColumnCount,
+ rightGutterColumnCount: addedLayout.rightGutterColumnCount,
+ },
+ });
+ itemSectionLayoutsToInsert.push(...projectedLayout.itemSectionLayouts);
+ sectionLayoutsToInsert.push(...projectedLayout.sectionLayouts);
+ }
+
+ const itemSectionLayoutsToUpdate: InferInsertModel[] = [];
+ const sectionLayoutsToUpdate: InferInsertModel[] = [];
+ const layoutsToUpdate = filterUpdatedItems(input.layouts, board.layouts);
+
+ for (const updatedLayout of layoutsToUpdate) {
+ const dbLayout = existingLayoutsById.get(updatedLayout.id);
+ if (
+ !dbLayout ||
+ (dbLayout.columnCount === updatedLayout.columnCount &&
+ dbLayout.leftGutterColumnCount === updatedLayout.leftGutterColumnCount &&
+ dbLayout.rightGutterColumnCount === updatedLayout.rightGutterColumnCount)
+ )
+ continue;
+
+ const projectedLayout =
+ updatedLayout.role === "base" && resizedBaseLayout
+ ? resizedBaseLayout
+ : getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: dbLayout.id,
+ columnCount: dbLayout.columnCount,
+ leftGutterColumnCount: dbLayout.leftGutterColumnCount,
+ rightGutterColumnCount: dbLayout.rightGutterColumnCount,
+ },
+ current: {
+ layoutId: dbLayout.id,
+ columnCount: updatedLayout.columnCount,
+ leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
+ rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
+ },
+ });
+ itemSectionLayoutsToUpdate.push(...projectedLayout.itemSectionLayouts);
+ sectionLayoutsToUpdate.push(...projectedLayout.sectionLayouts);
+ }
+
+ const removedLayoutIds = removedLayouts.map((layout) => layout.id);
+
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await db.transaction(async (transaction) => {
+ if (layoutsToInsert.length > 0) await transaction.insert(schema.layouts).values(layoutsToInsert);
+ if (itemSectionLayoutsToInsert.length > 0) {
+ await transaction.insert(schema.itemLayouts).values(itemSectionLayoutsToInsert);
+ }
+ if (sectionLayoutsToInsert.length > 0) {
+ await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
+ }
+
+ for (const itemLayout of itemSectionLayoutsToUpdate) {
+ await transaction
+ .update(schema.itemLayouts)
+ .set({
+ height: itemLayout.height,
+ width: itemLayout.width,
+ xOffset: itemLayout.xOffset,
+ yOffset: itemLayout.yOffset,
+ sectionId: itemLayout.sectionId,
+ })
+ .where(
+ and(
+ eq(schema.itemLayouts.itemId, itemLayout.itemId),
+ eq(schema.itemLayouts.layoutId, itemLayout.layoutId),
+ ),
+ );
+ }
+ for (const sectionLayout of sectionLayoutsToUpdate) {
+ await transaction
+ .update(schema.sectionLayouts)
+ .set({
+ height: sectionLayout.height,
+ width: sectionLayout.width,
+ xOffset: sectionLayout.xOffset,
+ yOffset: sectionLayout.yOffset,
+ parentSectionId: sectionLayout.parentSectionId,
+ })
+ .where(
+ and(
+ eq(schema.sectionLayouts.sectionId, sectionLayout.sectionId),
+ eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId),
+ ),
+ );
+ }
+ for (const layout of layoutsToUpdate) {
+ await transaction
+ .update(schema.layouts)
+ .set({
+ name: layout.name,
+ columnCount: layout.columnCount,
+ leftGutterColumnCount: layout.leftGutterColumnCount,
+ rightGutterColumnCount: layout.rightGutterColumnCount,
+ breakpoint: layout.breakpoint,
+ })
+ .where(eq(schema.layouts.id, layout.id));
+ }
+ if (removedLayoutIds.length > 0) {
+ await transaction.delete(schema.layouts).where(inArray(schema.layouts.id, removedLayoutIds));
+ }
+ });
+ },
+ handleSync(db) {
+ db.transaction((transaction) => {
+ if (layoutsToInsert.length > 0) transaction.insert(layouts).values(layoutsToInsert).run();
+ if (itemSectionLayoutsToInsert.length > 0) {
+ transaction.insert(itemLayouts).values(itemSectionLayoutsToInsert).run();
+ }
+ if (sectionLayoutsToInsert.length > 0) {
+ transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
+ }
+
+ for (const itemLayout of itemSectionLayoutsToUpdate) {
+ transaction
+ .update(itemLayouts)
+ .set({
+ height: itemLayout.height,
+ width: itemLayout.width,
+ xOffset: itemLayout.xOffset,
+ yOffset: itemLayout.yOffset,
+ sectionId: itemLayout.sectionId,
+ })
+ .where(and(eq(itemLayouts.itemId, itemLayout.itemId), eq(itemLayouts.layoutId, itemLayout.layoutId)))
+ .run();
+ }
+ for (const sectionLayout of sectionLayoutsToUpdate) {
+ transaction
+ .update(sectionLayouts)
+ .set({
+ height: sectionLayout.height,
+ width: sectionLayout.width,
+ xOffset: sectionLayout.xOffset,
+ yOffset: sectionLayout.yOffset,
+ parentSectionId: sectionLayout.parentSectionId,
+ })
+ .where(
+ and(
+ eq(sectionLayouts.sectionId, sectionLayout.sectionId),
+ eq(sectionLayouts.layoutId, sectionLayout.layoutId),
+ ),
+ )
+ .run();
+ }
+ for (const layout of layoutsToUpdate) {
+ transaction
+ .update(layouts)
+ .set({
+ name: layout.name,
+ columnCount: layout.columnCount,
+ leftGutterColumnCount: layout.leftGutterColumnCount,
+ rightGutterColumnCount: layout.rightGutterColumnCount,
+ breakpoint: layout.breakpoint,
+ })
+ .where(eq(layouts.id, layout.id))
+ .run();
+ }
+ if (removedLayoutIds.length > 0)
+ transaction.delete(layouts).where(inArray(layouts.id, removedLayoutIds)).run();
+ });
},
});
- layoutsToInsert.push({
- id: layoutId,
- name: addedLayout.name,
- columnCount: addedLayout.columnCount,
- leftGutterColumnCount: addedLayout.leftGutterColumnCount,
- rightGutterColumnCount: addedLayout.rightGutterColumnCount,
- breakpoint: addedLayout.breakpoint,
- boardId: board.id,
- });
+ return input.layouts
+ .map((layout) => ({
+ ...layout,
+ id: savedLayoutIds.get(layout.id) ?? layout.id,
+ role: existingLayoutsById.get(layout.id)?.role ?? "custom",
+ }))
+ .toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint);
+ }),
+ resetLayout: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Reset a board's Mobile or custom layout from its Base layout while preserving the target layout settings. Requires modify permission. REQUIRED: boardId (board ID), layoutId (non-Base layout ID)",
+ },
+ })
+ .input(boardResetLayoutSchema)
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
- itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
- sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
- }
+ const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.boardId), ctx.session.user.id);
+ const targetLayout = board.layouts.find((layout) => layout.id === input.layoutId);
+ const baseLayout = board.layouts.find((layout) => layout.role === "base");
- if (layoutsToInsert.length > 0) {
- await ctx.db.insert(layouts).values(layoutsToInsert);
- }
-
- if (itemSectionLayoutsToInsert.length > 0) {
- await ctx.db.insert(itemLayouts).values(itemSectionLayoutsToInsert);
- }
-
- if (sectionLayoutsToInsert.length > 0) {
- await ctx.db.insert(sectionLayouts).values(sectionLayoutsToInsert);
- }
-
- const updatedLayouts = filterUpdatedItems(input.layouts, board.layouts);
- for (const updatedLayout of updatedLayouts) {
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id)!;
- let updatedBoardLayout: ReturnType | undefined;
-
- if (
- dbLayout.columnCount !== updatedLayout.columnCount ||
- dbLayout.leftGutterColumnCount !== updatedLayout.leftGutterColumnCount ||
- dbLayout.rightGutterColumnCount !== updatedLayout.rightGutterColumnCount
- ) {
- updatedBoardLayout = getUpdatedBoardLayout(board, {
- previous: {
- layoutId: dbLayout.id,
- columnCount: dbLayout.columnCount,
- leftGutterColumnCount: dbLayout.leftGutterColumnCount,
- rightGutterColumnCount: dbLayout.rightGutterColumnCount,
- },
- current: {
- layoutId: dbLayout.id,
- columnCount: updatedLayout.columnCount,
- leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
- rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
- },
- });
-
- for (const itemSectionLayout of updatedBoardLayout.itemSectionLayouts) {
- await ctx.db
- .update(itemLayouts)
- .set({
- height: itemSectionLayout.height,
- width: itemSectionLayout.width,
- xOffset: itemSectionLayout.xOffset,
- yOffset: itemSectionLayout.yOffset,
- sectionId: itemSectionLayout.sectionId,
- })
- .where(
- and(
- eq(itemLayouts.itemId, itemSectionLayout.itemId),
- eq(itemLayouts.layoutId, itemSectionLayout.layoutId),
- ),
- );
- }
-
- for (const sectionLayout of updatedBoardLayout.sectionLayouts) {
- await ctx.db
- .update(sectionLayouts)
- .set({
- height: sectionLayout.height,
- width: sectionLayout.width,
- xOffset: sectionLayout.xOffset,
- yOffset: sectionLayout.yOffset,
- parentSectionId: sectionLayout.parentSectionId,
- })
- .where(
- and(
- eq(sectionLayouts.sectionId, sectionLayout.sectionId),
- eq(sectionLayouts.layoutId, sectionLayout.layoutId),
- ),
- );
- }
+ if (!targetLayout) throw new TRPCError({ code: "NOT_FOUND", message: "Layout not found" });
+ if (!baseLayout) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a Base layout" });
+ }
+ if (targetLayout.role === "base") {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "The Base layout cannot be reset" });
}
- await ctx.db
- .update(layouts)
- .set({
- name: updatedLayout.name,
- columnCount: updatedLayout.columnCount,
- leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
- rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
- breakpoint: updatedLayout.breakpoint,
- })
- .where(eq(layouts.id, updatedLayout.id));
- }
+ const projectedLayout = getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: baseLayout.id,
+ columnCount: baseLayout.columnCount,
+ leftGutterColumnCount: baseLayout.leftGutterColumnCount,
+ rightGutterColumnCount: baseLayout.rightGutterColumnCount,
+ },
+ current: {
+ layoutId: targetLayout.id,
+ columnCount: targetLayout.columnCount,
+ leftGutterColumnCount: targetLayout.leftGutterColumnCount,
+ rightGutterColumnCount: targetLayout.rightGutterColumnCount,
+ },
+ });
- const removedLayouts = filterRemovedItems(input.layouts, board.layouts);
- const removedLayoutIds = removedLayouts.map((layout) => layout.id);
- if (removedLayoutIds.length > 0) {
- await ctx.db.delete(layouts).where(inArray(layouts.id, removedLayoutIds));
- }
- }),
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await db.transaction(async (transaction) => {
+ await transaction.delete(schema.itemLayouts).where(eq(schema.itemLayouts.layoutId, targetLayout.id));
+ await transaction.delete(schema.sectionLayouts).where(eq(schema.sectionLayouts.layoutId, targetLayout.id));
+ if (projectedLayout.itemSectionLayouts.length > 0) {
+ await transaction.insert(schema.itemLayouts).values(projectedLayout.itemSectionLayouts);
+ }
+ if (projectedLayout.sectionLayouts.length > 0) {
+ await transaction.insert(schema.sectionLayouts).values(projectedLayout.sectionLayouts);
+ }
+ });
+ },
+ handleSync(db) {
+ db.transaction((transaction) => {
+ transaction.delete(itemLayouts).where(eq(itemLayouts.layoutId, targetLayout.id)).run();
+ transaction.delete(sectionLayouts).where(eq(sectionLayouts.layoutId, targetLayout.id)).run();
+ if (projectedLayout.itemSectionLayouts.length > 0) {
+ transaction.insert(itemLayouts).values(projectedLayout.itemSectionLayouts).run();
+ }
+ if (projectedLayout.sectionLayouts.length > 0) {
+ transaction.insert(sectionLayouts).values(projectedLayout.sectionLayouts).run();
+ }
+ });
+ },
+ });
+ }),
savePartialBoardSettings: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
@@ -1556,13 +1840,14 @@ export const boardRouter = createTRPCRouter({
}
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
+ const sizeForLayout = { ...defaultSize, width: Math.min(layout.columnCount, defaultSize.width) };
const fitsAt = (x: number, y: number) => {
- if (x + defaultSize.width > layout.columnCount) return false;
- for (let dy = 0; dy < defaultSize.height; dy++) {
+ if (x + sizeForLayout.width > layout.columnCount) return false;
+ for (let dy = 0; dy < sizeForLayout.height; dy++) {
const row = occupied[y + dy];
if (!row) continue;
- for (let dx = 0; dx < defaultSize.width; dx++) {
+ for (let dx = 0; dx < sizeForLayout.width; dx++) {
if (row[x + dx]) return false;
}
}
@@ -1580,8 +1865,8 @@ export const boardRouter = createTRPCRouter({
layoutId: layout.id,
xOffset: x,
yOffset: y,
- width: defaultSize.width,
- height: defaultSize.height,
+ width: sizeForLayout.width,
+ height: sizeForLayout.height,
});
placed = true;
}
@@ -1691,14 +1976,43 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
}
};
+interface BoardForLayoutProjection {
+ id: string;
+ items: Array<{
+ id: string;
+ layouts: Array<{
+ layoutId: string;
+ sectionId: string;
+ width: number;
+ height: number;
+ xOffset: number;
+ yOffset: number;
+ }>;
+ }>;
+ sections: Array<{
+ id: string;
+ kind: string;
+ xOffset?: number | null;
+ yOffset?: number | null;
+ layouts?: Array<{
+ layoutId: string;
+ parentSectionId: string | null;
+ width: number;
+ height: number;
+ xOffset: number;
+ yOffset: number;
+ }>;
+ }>;
+}
+
const getUpdatedBoardLayout = (
- board: Awaited>,
+ board: BoardForLayoutProjection,
options: {
- previous: BoardLayoutGeometry;
+ previous: BoardLayoutGeometry & { elements?: GridAlgorithmItem[] };
current: BoardLayoutGeometry;
},
) => {
- const elements = getElementsForLayout(board, options.previous.layoutId);
+ const elements = options.previous.elements ?? getElementsForLayout(board, options.previous.layoutId);
const emptyRoots = board.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.yOffset ?? 0) - (second.yOffset ?? 0) || first.id.localeCompare(second.id));
@@ -1734,7 +2048,12 @@ const getUpdatedBoardLayout = (
const root = rootByLane.get(lane);
const width = getBoardLaneColumnCount(options.current, lane);
if (!root || width === 0) return [];
- const sourceLanes = boardLanes.filter((sourceLane) => targetLaneBySourceLane.get(sourceLane) === lane);
+ const sourceLanes = boardLanes.filter(
+ (sourceLane) =>
+ rootByLane.has(sourceLane) &&
+ getBoardLaneColumnCount(options.previous, sourceLane) > 0 &&
+ targetLaneBySourceLane.get(sourceLane) === lane,
+ );
const previousWidth =
sourceLanes.length === 1
? getBoardLaneColumnCount(options.previous, sourceLanes[0] ?? "main")
@@ -1793,6 +2112,35 @@ const getUpdatedBoardLayout = (
};
};
+const getElementsForProjectedLayout = (
+ projectedLayout: ReturnType,
+): GridAlgorithmItem[] => [
+ ...projectedLayout.itemSectionLayouts.map((layout) => ({
+ id: layout.itemId,
+ type: "item" as const,
+ height: layout.height,
+ width: layout.width,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ sectionId: layout.sectionId,
+ })),
+ ...projectedLayout.sectionLayouts.flatMap((layout) =>
+ layout.parentSectionId
+ ? [
+ {
+ id: layout.sectionId,
+ type: "section" as const,
+ height: layout.height,
+ width: layout.width,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ sectionId: layout.parentSectionId,
+ },
+ ]
+ : [],
+ ),
+];
+
interface BoardLayoutGeometry {
layoutId: string;
columnCount: number;
@@ -1873,43 +2221,54 @@ const ensureGutterRootSectionsAsync = async (db: Database, boardId: string, lane
});
};
-const getElementsForLayout = (board: Awaited>, layoutId: string) => {
+const getElementsForLayout = (board: BoardForLayoutProjection, layoutId: string) => {
const sectionElements = board.sections
.filter((section) => section.kind === "container")
- .map((section) => {
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const clonedLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layoutId)!;
+ .flatMap((section) => {
+ const clonedLayout = section.layouts?.find((sectionLayout) => sectionLayout.layoutId === layoutId);
+ if (!clonedLayout?.parentSectionId) return [];
- return {
- id: section.id,
- type: "section" as const,
+ return [
+ {
+ id: section.id,
+ type: "section" as const,
+ height: clonedLayout.height,
+ width: clonedLayout.width,
+ xOffset: clonedLayout.xOffset,
+ yOffset: clonedLayout.yOffset,
+ sectionId: clonedLayout.parentSectionId,
+ },
+ ];
+ });
+
+ const itemElements = board.items.flatMap((item) => {
+ const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId);
+ if (!clonedLayout) return [];
+
+ return [
+ {
+ id: item.id,
+ type: "item" as const,
height: clonedLayout.height,
width: clonedLayout.width,
xOffset: clonedLayout.xOffset,
yOffset: clonedLayout.yOffset,
- sectionId: clonedLayout.parentSectionId,
- };
- });
-
- const itemElements = board.items.map((item) => {
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId)!;
-
- return {
- id: item.id,
- type: "item" as const,
- height: clonedLayout.height,
- width: clonedLayout.width,
- xOffset: clonedLayout.xOffset,
- yOffset: clonedLayout.yOffset,
- sectionId: clonedLayout.sectionId,
- };
+ sectionId: clonedLayout.sectionId,
+ },
+ ];
});
return [...itemElements, ...sectionElements];
};
-const getFullBoardWithWhereAsync = async (db: Database, where: SQL, userId: string | null) => {
+const protectedLayoutRepairPromises = new Map>();
+
+const getFullBoardWithWhereAsync = async (
+ db: Database,
+ where: SQL,
+ userId: string | null,
+ repairProtectedLayouts = true,
+) => {
const groupPermissionWhere = userId
? inArray(
boardGroupPermissions.groupId,
@@ -1965,6 +2324,18 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use
});
}
+ if (repairProtectedLayouts && boardLayoutsNeedRepair(board.layouts)) {
+ let repairPromise = protectedLayoutRepairPromises.get(board.id);
+ if (!repairPromise) {
+ repairPromise = seedProtectedBoardLayoutsAsync(db, board.id).finally(() => {
+ protectedLayoutRepairPromises.delete(board.id);
+ });
+ protectedLayoutRepairPromises.set(board.id, repairPromise);
+ }
+ await repairPromise;
+ return getFullBoardWithWhereAsync(db, where, userId, false);
+ }
+
const { sections, items, layouts, ...otherBoardProperties } = board;
return {
@@ -2010,6 +2381,23 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use
};
};
+export const boardLayoutsNeedRepair = (
+ boardLayouts: Array<{ id: string; breakpoint: number; role: "mobile" | "base" | "custom" }>,
+) => {
+ 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
+ );
+};
+
const forKind = (kind: T) =>
z.object({
kind: z.literal(kind),
diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts
index 5fdfd191e..625325dcf 100644
--- a/packages/api/src/router/test/board.spec.ts
+++ b/packages/api/src/router/test/board.spec.ts
@@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db";
-import { and, eq, not } from "@homarr/db";
+import { and, eq } from "@homarr/db";
import {
boardGroupPermissions,
boards,
@@ -325,10 +325,13 @@ describe("createBoard should create a new board", () => {
expect(firstSection?.xOffset).toBe(0);
expect(firstSection?.yOffset).toBe(0);
- expect(dbBoard?.layouts.length).toBe(1);
- const firstLayout = dbBoard?.layouts.at(0);
- expect(firstLayout?.columnCount).toBe(24);
- expect(firstLayout?.breakpoint).toBe(0);
+ expect(dbBoard?.layouts.length).toBe(2);
+ expect(dbBoard?.layouts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }),
+ expect.objectContaining({ name: "Base", role: "base", columnCount: 24, breakpoint: 768 }),
+ ]),
+ );
});
test("should throw error when user has no board-create permission", async () => {
@@ -344,6 +347,36 @@ describe("createBoard should create a new board", () => {
});
});
+describe("duplicateBoard should preserve protected layout invariants", () => {
+ test("normalizes a legacy single-layout board while preserving Base positions", async () => {
+ const db = createDb();
+ const session = {
+ ...defaultSession,
+ user: {
+ ...defaultSession.user,
+ permissions: ["board-create"] satisfies GroupPermissionKey[],
+ },
+ };
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
+ const { boardId } = await createFullBoardAsync(db, "source");
+
+ const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
+
+ const copiedBoard = await db.query.boards.findFirst({
+ where: eq(boards.id, result.boardId),
+ with: { layouts: true, items: { with: { layouts: true } } },
+ });
+ expect(copiedBoard?.layouts).toHaveLength(2);
+ expect(copiedBoard?.layouts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
+ expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
+ ]),
+ );
+ expect(copiedBoard?.items.at(0)?.layouts).toHaveLength(2);
+ });
+});
+
describe("rename board should rename board", () => {
test("should rename board", async () => {
// Arrange
@@ -569,6 +602,13 @@ describe("getBoardByName should return board by name", () => {
name,
...fullBoardProps,
});
+ expect(result.layouts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ role: "mobile", breakpoint: 0, columnCount: 3 }),
+ expect.objectContaining({ id: fullBoardProps.layoutId, role: "base", breakpoint: 768 }),
+ ]),
+ );
+ expect(result.items.at(0)?.layouts).toHaveLength(2);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
@@ -1330,12 +1370,12 @@ describe("saveBoard should save full board", () => {
expect(firstItem.id).toBe(itemId);
expect(firstItem.kind).toBe("clock");
expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
- const firstLayout = expectToBeDefined(firstItem.layouts[0]);
- expect(firstLayout.sectionId).toBe(sectionId);
- expect(firstLayout.height).toBe(3);
- expect(firstLayout.width).toBe(2);
- expect(firstLayout.xOffset).toBe(7);
- expect(firstLayout.yOffset).toBe(5);
+ const updatedLayout = expectToBeDefined(firstItem.layouts.find((layout) => layout.layoutId === layoutId));
+ expect(updatedLayout.sectionId).toBe(sectionId);
+ expect(updatedLayout.height).toBe(3);
+ expect(updatedLayout.width).toBe(2);
+ expect(updatedLayout.xOffset).toBe(7);
+ expect(updatedLayout.yOffset).toBe(5);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should fail when board not found", async () => {
@@ -1522,7 +1562,17 @@ const createExistingLayout = (id: string) => ({
columnCount: 10,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
+ breakpoint: 768,
+ role: "base" as const,
+});
+const createMobileLayout = (id: string) => ({
+ id,
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
breakpoint: 0,
+ role: "mobile" as const,
});
const createNewLayout = (columnCount: number) => ({
id: createId(),
@@ -1530,8 +1580,23 @@ const createNewLayout = (columnCount: number) => ({
columnCount,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
- breakpoint: 1400,
+ breakpoint: 480,
+ role: "custom" as const,
});
+const addMobileLayoutAsync = async (db: Database, boardId: string) => {
+ const mobileLayoutId = createId();
+ await db.insert(layouts).values({
+ id: mobileLayoutId,
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 0,
+ role: "mobile",
+ boardId,
+ });
+ return mobileLayoutId;
+};
describe("saveLayouts should save layout changes", () => {
test("should add layout when not present in database", async () => {
// Arrange
@@ -1539,23 +1604,26 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const newLayout = createNewLayout(12);
// Act
- await caller.saveLayouts({
+ const savedLayouts = await caller.saveLayouts({
id: boardId,
- layouts: [createExistingLayout(layoutId), newLayout],
+ layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
- where: not(eq(layouts.id, layoutId)),
+ where: eq(layouts.role, "custom"),
});
const definedLayout = expectToBeDefined(layout);
expect(definedLayout.name).toBe(newLayout.name);
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
+ expect(definedLayout.role).toBe("custom");
+ expect(savedLayouts.find((candidate) => candidate.role === "custom")?.id).toBe(definedLayout.id);
});
test("should add items and containers generated from grid-algorithm when new layout is added", async () => {
// Arrange
@@ -1563,6 +1631,7 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
@@ -1573,12 +1642,12 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
- layouts: [createExistingLayout(layoutId), newLayout],
+ layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
- where: not(eq(layouts.id, layoutId)),
+ where: eq(layouts.role, "custom"),
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expectLayoutForRootLayoutAsync(db, sectionId, layout!.id, {
@@ -1594,6 +1663,7 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const updatedLayout = createExistingLayout(layoutId);
updatedLayout.breakpoint = 1400;
updatedLayout.name = "Updated layout";
@@ -1601,7 +1671,7 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
- layouts: [updatedLayout],
+ layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
});
// Assert
@@ -1620,6 +1690,7 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
@@ -1631,7 +1702,7 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
- layouts: [updatedLayout],
+ layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
});
// Assert
@@ -1645,6 +1716,7 @@ describe("saveLayouts should save layout changes", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "gutters");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
await db
.update(itemLayouts)
@@ -1656,6 +1728,7 @@ describe("saveLayouts should save layout changes", () => {
await caller.saveLayouts({
id: boardId,
layouts: [
+ createMobileLayout(mobileLayoutId),
{
...createExistingLayout(layoutId),
leftGutterColumnCount: 2,
@@ -1684,7 +1757,7 @@ describe("saveLayouts should save layout changes", () => {
});
await caller.saveLayouts({
id: boardId,
- layouts: [createExistingLayout(layoutId)],
+ layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
});
const returnedItem = expectToBeDefined(
@@ -1698,9 +1771,11 @@ describe("saveLayouts should save layout changes", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "concurrent-gutters");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const input = {
id: boardId,
layouts: [
+ createMobileLayout(mobileLayoutId),
{
...createExistingLayout(layoutId),
leftGutterColumnCount: 2,
@@ -1715,22 +1790,73 @@ describe("saveLayouts should save layout changes", () => {
});
expect(leftRoots).toHaveLength(1);
});
+ test("should project a new custom layout from the resized Base layout", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
+ const secondItemId = createId();
+
+ await db.update(itemLayouts).set({ width: 4, xOffset: 0 }).where(eq(itemLayouts.itemId, itemId));
+ await db.insert(items).values({
+ id: secondItemId,
+ kind: "clock",
+ boardId,
+ options: SuperJSON.stringify({ is24HourFormat: true }),
+ });
+ await db.insert(itemLayouts).values({
+ itemId: secondItemId,
+ layoutId,
+ sectionId,
+ width: 4,
+ height: 1,
+ xOffset: 6,
+ yOffset: 0,
+ });
+
+ const resizedBase = { ...createExistingLayout(layoutId), columnCount: 6 };
+ const savedLayouts = await caller.saveLayouts({
+ id: boardId,
+ layouts: [createMobileLayout(mobileLayoutId), createNewLayout(8), resizedBase],
+ });
+ const customLayoutId = expectToBeDefined(savedLayouts.find((layout) => layout.role === "custom")).id;
+
+ const basePosition = await db.query.itemLayouts.findFirst({
+ where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, layoutId)),
+ });
+ const customPosition = await db.query.itemLayouts.findFirst({
+ where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, customLayoutId)),
+ });
+
+ expect(basePosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
+ expect(customPosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
+ });
test("should remove layout when not present in input", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
+ const customLayoutId = createId();
+ await db.insert(layouts).values({
+ id: customLayoutId,
+ name: "Custom",
+ columnCount: 6,
+ breakpoint: 480,
+ role: "custom",
+ boardId,
+ });
// Act
await caller.saveLayouts({
id: boardId,
- layouts: [createNewLayout(12)],
+ layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
});
// Assert
const layout = await db.query.layouts.findFirst({
- where: eq(layouts.id, layoutId),
+ where: eq(layouts.id, customLayoutId),
});
expect(layout).toBeUndefined();
});
@@ -1745,12 +1871,73 @@ describe("saveLayouts should save layout changes", () => {
const actAsync = async () =>
await caller.saveLayouts({
id: createId(),
- layouts: [createExistingLayout(layoutId)],
+ layouts: [createMobileLayout(createId()), createExistingLayout(layoutId)],
});
// Assert
await expect(actAsync()).rejects.toThrowError("Board not found");
});
+ test("should reject changing protected layout roles", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
+
+ const actAsync = async () =>
+ await caller.saveLayouts({
+ id: boardId,
+ layouts: [
+ { ...createExistingLayout(layoutId), role: "mobile", breakpoint: 0 },
+ { ...createMobileLayout(mobileLayoutId), role: "base", breakpoint: 768 },
+ ],
+ });
+
+ await expect(actAsync()).rejects.toThrowError("Layout roles cannot be changed");
+ });
+});
+
+describe("resetLayout should rebuild a layout from Base", () => {
+ test("should replace target item positions and preserve layout metadata", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ await addMobileLayoutAsync(db, boardId);
+ const customLayoutId = createId();
+ await db.insert(layouts).values({
+ id: customLayoutId,
+ name: "Tablet",
+ columnCount: 6,
+ breakpoint: 480,
+ role: "custom",
+ boardId,
+ });
+ await db.insert(itemLayouts).values({
+ itemId,
+ sectionId,
+ layoutId: customLayoutId,
+ width: 2,
+ height: 2,
+ xOffset: 3,
+ yOffset: 4,
+ });
+
+ await caller.resetLayout({ boardId, layoutId: customLayoutId });
+
+ const resetPosition = await db.query.itemLayouts.findFirst({
+ where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, customLayoutId)),
+ });
+ expect(resetPosition).toMatchObject({ width: 1, height: 1, xOffset: 0, yOffset: 0, sectionId });
+ const customLayout = await db.query.layouts.findFirst({ where: eq(layouts.id, customLayoutId) });
+ expect(customLayout).toMatchObject({ name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" });
+ });
+
+ test("should reject resetting Base", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+
+ await expect(caller.resetLayout({ boardId, layoutId })).rejects.toThrowError("Base layout cannot be reset");
+ });
});
describe("Custom Widget placement permissions", () => {
@@ -1848,7 +2035,8 @@ const createFullBoardAsync = async (db: Database, name: string) => {
id: layoutId,
name: "Base",
columnCount: 10,
- breakpoint: 0,
+ breakpoint: 768,
+ role: "base",
boardId,
});
diff --git a/packages/boards/src/context.tsx b/packages/boards/src/context.tsx
index b6e1ea3a6..2e8137b5e 100644
--- a/packages/boards/src/context.tsx
+++ b/packages/boards/src/context.tsx
@@ -12,6 +12,7 @@ import { updateBoardName } from "./updater";
const BoardContext = createContext<{
board: RouterOutputs["board"]["getBoardByName"];
+ layoutOverrideId: string | null;
currentLayout: string;
initialViewportWidth: number;
} | null>(null);
@@ -41,10 +42,12 @@ export const BoardProvider = ({
initialBoard,
initialLayoutId,
initialViewportWidth,
+ layoutOverrideId = null,
}: PropsWithChildren<{
initialBoard: RouterOutputs["board"]["getBoardByName"];
initialLayoutId: string;
initialViewportWidth: number;
+ layoutOverrideId?: string | null;
}>) => {
const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name },
@@ -58,8 +61,8 @@ export const BoardProvider = ({
);
const currentLayout = useSyncExternalStore(
subscribeToViewport,
- () => getCurrentLayout(data),
- () => initialLayoutId,
+ () => getCurrentLayout(data, layoutOverrideId),
+ () => layoutOverrideId ?? initialLayoutId,
);
// Update the board name so it can be used within updateBoard method
@@ -83,6 +86,7 @@ export const BoardProvider = ({
{
return context?.board ?? null;
};
-export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => {
+export const useLayoutOverride = () => useContext(BoardContext)?.layoutOverrideId ?? null;
+
+export const getCurrentLayout = (
+ board: RouterOutputs["board"]["getBoardByName"],
+ layoutOverrideId: string | null = null,
+) => {
+ const layouts = board.layouts;
+ if (layoutOverrideId && layouts.some((layout) => layout.id === layoutOverrideId)) return layoutOverrideId;
+
const viewportWidth = typeof window === "undefined" ? 0 : getViewportWidth();
- return getLayoutIdForViewportWidth(board.layouts, viewportWidth);
+ return getLayoutIdForViewportWidth(layouts, viewportWidth);
};
export const useCurrentLayout = () => {
diff --git a/packages/boards/src/edit-mode.tsx b/packages/boards/src/edit-mode.tsx
index 9b813b032..6ca606b99 100644
--- a/packages/boards/src/edit-mode.tsx
+++ b/packages/boards/src/edit-mode.tsx
@@ -6,12 +6,21 @@ import { useDisclosure } from "@mantine/hooks";
const EditModeContext = createContext | null>(null);
+const noop = () => undefined;
+const readOnlyEditMode = [false, { open: noop, close: noop, toggle: noop, set: noop }] satisfies ReturnType<
+ typeof useDisclosure
+>;
+
export const EditModeProvider = ({ children }: PropsWithChildren) => {
const editModeDisclosure = useDisclosure(false);
return {children};
};
+export const ReadOnlyEditModeProvider = ({ children }: PropsWithChildren) => (
+ {children}
+);
+
export const useEditMode = () => {
const context = useContext(EditModeContext);
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 7c0ed58ce..c03c52758 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -5318,11 +5318,12 @@
},
"layout": {
"title": "Layout",
- "responsive": {
- "title": "Responsive layouts",
- "action": {
- "add": "Add layout"
- }
+ "desktop": {
+ "title": "Desktop layout",
+ "description": "Configure the layout used on desktop and larger screens."
+ },
+ "mobile": {
+ "description": "On mobile, items are automatically arranged in two columns and cannot be moved or resized. Categories and dynamic sections are flattened into the item list."
}
},
"background": {
diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts
index 5bb77540a..f74ca67b7 100644
--- a/packages/validation/src/board.ts
+++ b/packages/validation/src/board.ts
@@ -5,6 +5,7 @@ import {
backgroundImageRepeats,
backgroundImageSizes,
boardPermissions,
+ layoutRoles,
widgetKinds,
} from "@homarr/definitions";
@@ -78,16 +79,59 @@ export const boardLayoutSchema = z
columnCount: boardColumnCountSchema,
leftGutterColumnCount: boardGutterColumnCountSchema.default(0),
rightGutterColumnCount: boardGutterColumnCountSchema.default(0),
- breakpoint: z.number().min(0).max(32767),
+ breakpoint: z.number().int().min(0).max(32767),
+ role: z.enum(layoutRoles.values),
})
.refine((layout) => layout.leftGutterColumnCount + layout.rightGutterColumnCount < layout.columnCount, {
message: "Gutters must leave at least one dashboard column",
path: ["columnCount"],
+ })
+ .refine(
+ (layout) => layout.role !== "mobile" || (layout.leftGutterColumnCount === 0 && layout.rightGutterColumnCount === 0),
+ {
+ message: "Mobile layouts cannot have sidebars",
+ path: ["leftGutterColumnCount"],
+ },
+ );
+
+export const responsiveBoardLayoutsSchema = z
+ .array(boardLayoutSchema)
+ .min(2)
+ .superRefine((layouts, ctx) => {
+ const mobileLayouts = layouts.filter((layout) => layout.role === "mobile");
+ const baseLayouts = layouts.filter((layout) => layout.role === "base");
+ const mobileLayout = mobileLayouts.at(0);
+ const baseLayout = baseLayouts.at(0);
+ if (mobileLayouts.length !== 1 || baseLayouts.length !== 1 || !mobileLayout || !baseLayout) {
+ ctx.addIssue({ code: "custom", message: "Boards require exactly one Mobile and one Base layout" });
+ return;
+ }
+
+ if (mobileLayout.breakpoint !== 0) {
+ 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" });
+ }
+
+ if (new Set(layouts.map((layout) => layout.id)).size !== layouts.length) {
+ ctx.addIssue({ code: "custom", message: "Layout IDs must be unique" });
+ }
});
export const boardSaveLayoutsSchema = z.object({
id: z.string(),
- layouts: z.array(boardLayoutSchema),
+ layouts: responsiveBoardLayoutsSchema,
+});
+
+export const boardResetLayoutSchema = z.object({
+ boardId: z.string(),
+ layoutId: z.string(),
});
export const boardSaveSchema = z.object({