diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx
index 2c5bd42e2..3fec73bb1 100644
--- a/apps/docs/docs/management/api/index.mdx
+++ b/apps/docs/docs/management/api/index.mdx
@@ -80,6 +80,6 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
- Change board visibility
- Update board visual and behavior settings
- Set desktop and mobile home boards for the current user
-- Configure global board defaults such as public home boards and status behavior
+- Configure global board defaults such as public home boards, automatic mobile layout mode, and status behavior
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx
index 1e4e47a4f..cb05f1ac1 100644
--- a/apps/docs/docs/management/boards/index.mdx
+++ b/apps/docs/docs/management/boards/index.mdx
@@ -9,9 +9,9 @@ tags:
- Layout
---
-import iconColorImage from './img/icon-color.png'
-import itemRadiusImage from './img/item-radius.png'
-import layoutLargeImage from './img/layout-large.png'
+import iconColorImage from "./img/icon-color.png";
+import itemRadiusImage from "./img/item-radius.png";
+import layoutLargeImage from "./img/layout-large.png";
Each dashboard is called a "board".
You can create as many boards as you want.
@@ -22,24 +22,29 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home)
Names of boards must be unique because this will be used in the URL to load the desired board.
## Create a new board
+
When creating a board, the availability of the board name will be checked for you.

### Name
+
The name must not include any special characters and must be at least one character long.
Underscores and hyphen-minus are allowed.
### Column count
+
Homarr has a complex drag and drop system that can be used from the browser.
The column count defines how many horizontal columns are available to drag and drop elements to.
### Public
+
This will enable public access for everyone - regardless of whether they are logged in or not.
This can be really useful if you want to have public boards in your company or for family and fiends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
## Board settings
+
You navigate to the board settings by clicking on these three dots:

@@ -49,7 +54,9 @@ Alternatively, you can also navigate from the board page itself using the cog ic

### General
+
#### Page title
+
This is the title that will be visible at the top left in the navigation bar:

@@ -57,6 +64,7 @@ This is the title that will be visible at the top left in the navigation bar:
This field is optional and Homarr will fall back to "Homarr" if nothing is set.
#### Meta title
+
This is the secondary title of the page that is not directly visible in the page itself.
Browser will use it to set the title of tabs:
@@ -65,64 +73,85 @@ Browser will use it to set the title of tabs:
Homarr will fall back to the board name and a suffix if nothing is set.
#### Logo image URL
+
This is the URL that is used for the image at the top left.
Homarr will fall back to the Homarr logo if nothing is set.
#### Favicon image URL
+
This is the URL that is used for the favicon in the tab title.
Homarr will fall back to the Homarr logo if nothing is set.
### Layout
-In this section you can configure the number of columns in the desktop layout.
+The available controls depend on the server-wide **Use automatic mobile layout** setting under `Management` > `Settings` > `Boards`.
-Items and sections can be moved and resized on desktop while the board is in edit mode.
-Changing the column count automatically adjusts item positions and sizes to fit the new grid.
+#### Automatic mobile layout
+
+When automatic mode is enabled, this section configures the desktop column count. Items and sections can be moved and resized on desktop while the board is in edit mode.
+
+Phones and small tablets display the same widgets in a generated, read-only two-column grid. Widget content and controls are unchanged. Items follow desktop visual order and retain their desktop footprint, clamped to two columns wide and three rows high.
+
+Categories and dynamic sections are flattened: their items participate directly in the generated grid instead of rendering nested layout containers. To rearrange, add, remove, or resize items, open the board on a larger screen.
-#### Mobile layout
+#### Saved responsive layouts
-Mobile boards use an automatic, read-only layout with two columns. Items follow the visual order of the desktop layout and are limited to two columns wide and three rows high.
+When automatic mode is disabled, boards use their saved responsive layouts and breakpoint selection. These layouts can still be edited on mobile.
-Categories and dynamic sections are not displayed as containers on mobile. Their items are included directly in the automatic grid, even when a category is collapsed on desktop. To rearrange, add, remove, or resize board items, open the board on a larger screen.
+Switching automatic mode on does not delete saved responsive layouts. They remain available if the server is switched back.
### Background
+
#### Background image URL
+
Homarr can display a background image behind apps and widgets.
This URL can be used to define what image should be shown.
#### Background image attachment
+
This defines whether the background should move when the user scrolls.
#### Background image size
+
This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page.
#### Background image repeat
+
This defines whether the background should be repeated if it cannot cover the entire page.
This option can be useful if you have patterns that can be repeated easily (e.g. patterns):

### Appearance
+
#### Primary color
+
Color that can be chosen freely to give accents to active elements.
+
#### Secondary color
+
Color that can be chosen freely to give accents.
+
#### Opacity
+
Opacity of the colors.
+
#### Icon color
+
Change the color of your bookmark / app icons.
-
+
#### Item radius
+
Change the radius of your items / categories.
-
-
+
### Custom CSS
+
This is the field where you can enter custom CSS.
The styling will only be applied on the respective board page.
@@ -131,14 +160,16 @@ The styling will only be applied on the respective board page.
### Behaviour
#### Disable app status
+
This will disable the status check for apps.
### Access control
+
With the access control, you can permit a single user or a group to your board.
The users and groups can have one of these three levels of access:
| **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) |
-|----------------------|------------|-----------|----------|--------------------------|
+| -------------------- | ---------- | --------- | -------- | ------------------------ |
| View board | X | | | |
| Modify board | X | X | X | |
| Full access | X | X | X | X |
@@ -157,18 +188,23 @@ After editing permissions, the save button below the table must be pressed.
Otherwise, all changes will be discarded.
### Danger zone
+
#### Rename
+
Renaming a board will break all existing links to it permanently with no redirect.
#### Board visibility
+
By default, boards will be private. This means only authenticated users that are authorized have access.
Making a board public will expose it to everyone - including unauthenticated users.
#### Delete
+
This will permanently delete the board.
This action is irreversible and cannot be undone.
## Delete boards
+
The deletion of boards is permanent and cannot be undone.
There are two ways to delete a board:
@@ -180,7 +216,7 @@ Both actions will execute the same deletion process.
## Duplicate boards
:::note
-Duplicating a board will not copy the users and groups that have access to the board
+Duplicating a board will not copy the users and groups that have access to the board
nor the integrations the user does not have access to.
:::
@@ -190,47 +226,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an

## Setting boards as your home board
+
The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL.
All the following paths will lead to the home board:
-- ``my-homarr.com``
-- ``my-homarr.com/boards``
+
+- `my-homarr.com`
+- `my-homarr.com/boards`
Your home board will have a special small badge at the top right in the list of boards:

### For yourself
+
To set the home board yourself, the easiest option is to set it directly in the list of boards:

Alternatively, you can also define it via your user profile:
+
1. Click on your user profile at the top right
-2. Click on ``Your preferences``
-3. Change the home board in the ``home board`` dropdown and click on ``Save``.
+2. Click on `Your preferences`
+3. Change the home board in the `home board` dropdown and click on `Save`.
### For other users
-1. Navigate to the users: ``Management`` > ``Users`` > ``Manage``
+1. Navigate to the users: `Management` > `Users` > `Manage`
2. Click on the username of the user that you want to adjust
-3. Select the desired home board via the dropdown and click on ``Save`` \
-
+3. Select the desired home board via the dropdown and click on `Save` \
+ 
### For user groups
-1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage``
-2. Go to tab ``Settings``
-3. Select the desired home board via the dropdown and click on ``Save`` \
-
+1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage`
+2. Go to tab `Settings`
+3. Select the desired home board via the dropdown and click on `Save` \
+ 
### For whole server
You can also set a global home board that will be used for all users that have not set a home board themselves.
It will also be used for anonymous users.
-1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards``
-2. Select the desired home board via the dropdown and click on ``Save`` \
-
+1. Navigate to the settings: `Management` > `Settings` > `Boards`
+2. Select the desired home board via the dropdown and click on `Save` \
+ 
:::note
For the global home board you can only select boards that are public.
diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx
index d7705e61a..165dbb95d 100644
--- a/apps/docs/docs/management/settings/index.mdx
+++ b/apps/docs/docs/management/settings/index.mdx
@@ -9,10 +9,12 @@ tags:
---
# Settings
+
The settings page has a few options that will be applied to the entire Homarr application.
They may require a full page reload for changes to apply.
## Analytics
+
By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements.
The data is never shared with any third-parties and is fully GDPR & CCPA compliant.
The software being used is [Umami Analytics](https://umami.is/) which is fully open source.
@@ -21,25 +23,29 @@ The main server software can be found [here](https://github.com/umami-software/u
A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions.
We understand that you might not want Homarr to communicate with our services.
-By unchecking the ``Send anonymous analytics`` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected.
-If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall.
+By unchecking the `Send anonymous analytics` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected.
+If you are paranoid, you can permanently block `umami.homarr.dev` from your DNS / firewall.
Additionally, you opt in to more data collection to help us provide more useful data.
### Integration data
+
Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them.
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
### Widget data
+
Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them.
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
### Users data
+
Enabling this optional data will send the count of users and whether you activated SSO.
This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs.
This is disabled by default.
## Search Engine Optimization (SEO)
+
Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages.
This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard.
Homarr is requesting the crawlers to not index Homarr instances by default for this reason.
@@ -47,22 +53,26 @@ If you use Homarr as a public website and want it to be searchable on these sear
Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index.
### No index
+
This will ask crawlers to not index your Homarr instance on any indexes.
It is up to the crawler to properly respect this request.
By default, this setting is enabled. Disabling it will allow indexing.
### No follow
+
If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website.
By default, this setting is enabled and will ask crawlers to not follow any links.
Crawler of different companies may behave differently when indexing is prohibited and following is allowed.
### No translate
+
Google and Chrome on Android will prompt users to translate pages that are not in their device language.
This can lead to some shifting and can be undesired in some cases. By enabling this,
you ask crawlers to never translate and never prompt users for translation.
It should be noted that some browsers will ignore this and translate regardless.
### No site links search box
+
This setting is exclusive to Google.
[Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing".
The user can search them directly in Google.
@@ -72,7 +82,8 @@ This setting is disabled by default.
## Boards
### Global home board
-You can set a global home board that will be used for all users that have not set a home board themselves.
+
+You can set a global home board that will be used for all users that have not set a home board themselves.
It will also be used for anonymous users.
Note that the board must be public to be used as a global home board.
@@ -82,6 +93,15 @@ You can set a global mobile board. It will be shown instead of the global home b
It will similar to the global home board be as fallback for normal users and for any anonymous users.
It also has to be public.
+### Automatic mobile layout
+
+**Use automatic mobile layout** controls how every board is rendered on phones and small tablets:
+
+- Enabled: Homarr squeezes the normal widgets into a generated, read-only two-column grid. Widget content is not replaced or simplified.
+- Disabled: Homarr uses each board's saved responsive layouts and permits layout editing on mobile.
+
+The setting is enabled by default for new instances. During upgrade, Homarr leaves it disabled when existing custom responsive layouts are detected. Saved responsive layouts are preserved while automatic mode is enabled.
+
### App status
#### Enable status by default
@@ -97,9 +117,11 @@ You can decide if the app status should be disabled for all boards.
## Appearance
### Default color scheme
+
You can set the default color scheme for all users that have not set a color scheme themselves.
## Culture
### Default language
-You can set the default language for all users that have not set a language themselves.
\ No newline at end of file
+
+You can set the default language for all users that have not set a language themselves.
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx
index ab7604fca..668dfa3e9 100644
--- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx
@@ -1,19 +1,42 @@
"use client";
-import { Button, Fieldset, Group, Input, Slider, Stack, Text } from "@mantine/core";
+import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { getDesktopLayout } from "@homarr/boards/context";
+import { createId } from "@homarr/common";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
-import { boardSaveLayoutSchema } from "@homarr/validation/board";
+import { boardSaveLayoutSchema, boardSaveLayoutsSchema } from "@homarr/validation/board";
import type { Board } from "../../_types";
interface Props {
board: Board;
+ enableAutomaticMobileLayout: boolean;
}
-export const LayoutSettingsContent = ({ board }: Props) => {
+
+const getNextAvailableBreakpoint = (layouts: Array<{ breakpoint: number }>) => {
+ const breakpoints = new Set(layouts.map((layout) => layout.breakpoint));
+ const commonBreakpoints = [0, 480, 768, 1024, 1200, 1440, 1920];
+ const commonBreakpoint = commonBreakpoints.find((breakpoint) => !breakpoints.has(breakpoint));
+ if (commonBreakpoint !== undefined) return commonBreakpoint;
+
+ for (let breakpoint = 0; breakpoint <= 32767; breakpoint++) {
+ if (!breakpoints.has(breakpoint)) return breakpoint;
+ }
+
+ throw new Error("No responsive layout breakpoints are available");
+};
+
+export const LayoutSettingsContent = ({ board, enableAutomaticMobileLayout }: Props) =>
+ enableAutomaticMobileLayout ? (
+
+ ) : (
+
+ );
+
+const AutomaticLayoutSettingsContent = ({ board }: Pick) => {
const t = useI18n();
const utils = clientApi.useUtils();
const desktopLayout = getDesktopLayout(board);
@@ -45,13 +68,20 @@ export const LayoutSettingsContent = ({ board }: Props) => {
{t("board.setting.section.layout.desktop.description")}
-
+
- {t("board.setting.section.layout.mobile.description")}
+ {t("board.setting.section.layout.mobile.automaticDescription")}
@@ -63,3 +93,124 @@ export const LayoutSettingsContent = ({ board }: Props) => {
);
};
+
+const ResponsiveLayoutSettingsContent = ({ board }: Pick) => {
+ const t = useI18n();
+ const utils = clientApi.useUtils();
+ const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
+ initialValues: {
+ layouts: board.layouts,
+ },
+ });
+ const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
+ onSuccess(layouts) {
+ const values = { layouts };
+ form.setValues(values);
+ form.resetDirty(values);
+ },
+ onSettled() {
+ void utils.board.getBoardByName.invalidate({ name: board.name });
+ void utils.board.getHomeBoard.invalidate();
+ },
+ });
+
+ return (
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx
index a728e8949..fd699b38c 100644
--- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx
@@ -90,7 +90,10 @@ export default async function BoardSettingsPage(props: Props) {
-
+
diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx
index 2388e2812..5416f0d7c 100644
--- a/apps/nextjs/src/app/[locale]/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/layout.tsx
@@ -107,6 +107,7 @@ export default async function Layout(props: {
board: {
homeBoardId: serverSettings.board.homeBoardId,
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
+ enableAutomaticMobileLayout: serverSettings.board.enableAutomaticMobileLayout,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
},
diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx
index 615c6ba38..a7202e911 100644
--- a/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/board-settings-form.tsx
@@ -33,6 +33,13 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
{...form.getInputProps("mobileHomeBoardId")}
/>
+ {tBoard("mobileLayout.title")}
+
+
{tBoard("status.title")}
({
defaultValues: ServerSettings[TKey];
children: (form: ReturnType>) => React.ReactNode;
}) => {
+ const router = useRouter();
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
@@ -24,6 +26,7 @@ export const CommonSettingsForm = ({
showSuccessNotification({
message: tSettings("notification.success.message"),
});
+ router.refresh();
},
onError() {
showErrorNotification({
diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts
index d4b2cb24d..9a0cf3d41 100644
--- a/apps/nextjs/src/components/board/items/actions/create-item.ts
+++ b/apps/nextjs/src/components/board/items/actions/create-item.ts
@@ -60,9 +60,15 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
+ const layoutItemSize = boardLayout
+ ? {
+ ...itemSize,
+ width: Math.min(itemSize.width, boardLayout.columnCount),
+ }
+ : itemSize;
const emptyPosition = boardLayout
- ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
+ ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, layoutItemSize)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
@@ -70,8 +76,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
}
return {
- width: itemSize.width,
- height: itemSize.height,
+ width: layoutItemSize.width,
+ height: layoutItemSize.height,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
diff --git a/apps/nextjs/src/components/board/items/actions/empty-position.ts b/apps/nextjs/src/components/board/items/actions/empty-position.ts
index fd91c98d2..2f3875627 100644
--- a/apps/nextjs/src/components/board/items/actions/empty-position.ts
+++ b/apps/nextjs/src/components/board/items/actions/empty-position.ts
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
size: { width: number; height: number } = { width: 1, height: 1 },
) => {
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
- for (let xOffset = 0; xOffset < columnCount; xOffset++) {
+ for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
const isOccupied = elements.some(
(element) =>
element.yOffset < yOffset + size.height &&
diff --git a/apps/nextjs/src/components/board/items/actions/move-and-resize-item.ts b/apps/nextjs/src/components/board/items/actions/move-and-resize-item.ts
index 0348b3f06..8b8f3a02f 100644
--- a/apps/nextjs/src/components/board/items/actions/move-and-resize-item.ts
+++ b/apps/nextjs/src/components/board/items/actions/move-and-resize-item.ts
@@ -1,9 +1,8 @@
-import { getCurrentLayout } from "@homarr/boards/context";
-
import type { Board } from "~/app/[locale]/boards/_types";
export interface MoveAndResizeItemInput {
itemId: string;
+ layoutId: string;
xOffset: number;
yOffset: number;
width: number;
@@ -11,10 +10,8 @@ export interface MoveAndResizeItemInput {
}
export const moveAndResizeItemCallback =
- ({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
+ ({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) =>
(previous: Board): Board => {
- const currentLayout = getCurrentLayout(previous);
-
return {
...previous,
items: previous.items.map((item) =>
@@ -23,7 +20,7 @@ export const moveAndResizeItemCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
- layout.layoutId !== currentLayout
+ layout.layoutId !== layoutId
? layout
: {
...layout,
diff --git a/apps/nextjs/src/components/board/items/actions/move-item-to-section.ts b/apps/nextjs/src/components/board/items/actions/move-item-to-section.ts
index 98d093826..6b7f8d314 100644
--- a/apps/nextjs/src/components/board/items/actions/move-item-to-section.ts
+++ b/apps/nextjs/src/components/board/items/actions/move-item-to-section.ts
@@ -1,9 +1,8 @@
-import { getCurrentLayout } from "@homarr/boards/context";
-
import type { Board } from "~/app/[locale]/boards/_types";
export interface MoveItemToSectionInput {
itemId: string;
+ layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -12,10 +11,8 @@ export interface MoveItemToSectionInput {
}
export const moveItemToSectionCallback =
- ({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
+ ({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) =>
(board: Board): Board => {
- const currentLayout = getCurrentLayout(board);
-
return {
...board,
items: board.items.map((item) =>
@@ -24,7 +21,7 @@ export const moveItemToSectionCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
- layout.layoutId !== currentLayout
+ layout.layoutId !== layoutId
? layout
: {
...layout,
diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts
index e2a0ff93c..bdef2d101 100644
--- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts
+++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts
@@ -1,4 +1,4 @@
-import { describe, expect, test, vi } from "vitest";
+import { afterEach, describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
@@ -10,6 +10,10 @@ import { ItemMockBuilder } from "./mocks/item-mock";
import { LayoutMockBuilder } from "./mocks/layout-mock";
describe("item actions create-item", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
test("should add it to first section", () => {
// Arrange
const itemKind = "clock";
@@ -108,4 +112,24 @@ describe("item actions create-item", () => {
{ height: 1, width: 1 },
);
});
+
+ test("clamps wide widgets to each retained layout", () => {
+ const desktopLayout = new LayoutMockBuilder({ id: "desktop", columnCount: 12 }).build();
+ const mobileLayout = new LayoutMockBuilder({ id: "mobile", columnCount: 1 }).build();
+ const board = new BoardMockBuilder()
+ .addLayout(desktopLayout)
+ .addLayout(mobileLayout)
+ .addEmptySection({ id: "root", yOffset: 0 })
+ .build();
+
+ const result = createItemCallback({ kind: "mediaMissing" })(board);
+ const item = result.items.at(0);
+
+ expect(item?.layouts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ layoutId: desktopLayout.id, width: 4 }),
+ expect.objectContaining({ layoutId: mobileLayout.id, width: 1 }),
+ ]),
+ );
+ });
});
diff --git a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts
index fcc194618..819af19f9 100644
--- a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts
+++ b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts
@@ -106,6 +106,10 @@ describe("get first empty position", () => {
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
});
+
+ test("does not place an item wider than the layout", () => {
+ expect(getFirstEmptyPosition([], 1, undefined, { width: 2, height: 1 })).toBeUndefined();
+ });
});
const createElementsFromLayout = (layout: string[][]) => {
diff --git a/apps/nextjs/src/components/board/items/actions/test/move-and-resize-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/move-and-resize-item.spec.ts
index de3e5804b..d8c10cdd5 100644
--- a/apps/nextjs/src/components/board/items/actions/test/move-and-resize-item.spec.ts
+++ b/apps/nextjs/src/components/board/items/actions/test/move-and-resize-item.spec.ts
@@ -1,6 +1,4 @@
-import { describe, expect, test, vi } from "vitest";
-
-import * as boardContext from "@homarr/boards/context";
+import { describe, expect, test } from "vitest";
import { moveAndResizeItemCallback } from "../move-and-resize-item";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
- const spy = vi.spyOn(boardContext, "getCurrentLayout");
- spy.mockReturnValue(layoutId);
const newPosition = {
xOffset: 20,
yOffset: 30,
@@ -25,7 +21,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
// Act
- const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
+ const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -43,8 +39,6 @@ describe("moveItemToSectionCallback should move item in section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
- const spy = vi.spyOn(boardContext, "getCurrentLayout");
- spy.mockReturnValue(layoutId);
const newPosition = {
xOffset: 20,
yOffset: 30,
@@ -56,7 +50,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
- const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
+ const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
diff --git a/apps/nextjs/src/components/board/items/actions/test/move-item-to-section.spec.ts b/apps/nextjs/src/components/board/items/actions/test/move-item-to-section.spec.ts
index 41e838ed2..5f930003a 100644
--- a/apps/nextjs/src/components/board/items/actions/test/move-item-to-section.spec.ts
+++ b/apps/nextjs/src/components/board/items/actions/test/move-item-to-section.spec.ts
@@ -1,6 +1,4 @@
-import { describe, expect, test, vi } from "vitest";
-
-import * as boardContext from "@homarr/boards/context";
+import { describe, expect, test } from "vitest";
import { moveItemToSectionCallback } from "../move-item-to-section";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -11,8 +9,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
- const spy = vi.spyOn(boardContext, "getCurrentLayout");
- spy.mockReturnValue(layoutId);
const newPosition = {
sectionId: "3",
xOffset: 20,
@@ -26,7 +22,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
// Act
- const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
+ const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -44,8 +40,6 @@ describe("moveItemToSectionCallback should move item to section", () => {
// Arrange
const itemToMove = "2";
const layoutId = "1";
- const spy = vi.spyOn(boardContext, "getCurrentLayout");
- spy.mockReturnValue(layoutId);
const newPosition = {
sectionId: "3",
xOffset: 20,
@@ -58,7 +52,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
- const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
+ const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.module.css b/apps/nextjs/src/components/board/mobile/mobile-board.module.css
index 902e77372..282bde4fe 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css
+++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css
@@ -16,5 +16,4 @@
.item > :global(.grid-stack-item-content) {
width: 100%;
height: 100%;
- overflow-y: auto;
}
diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts
index 3e06594d0..e6c415682 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts
+++ b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts
@@ -93,4 +93,25 @@ describe("createMobileBoardItems", () => {
expect(result.map((item) => item.id)).toEqual(["second", "first"]);
expect(result[1]).toMatchObject({ width: 1, height: 1 });
});
+
+ test("keeps useful desktop footprints within the two-by-three mobile limit", () => {
+ 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" }).build();
+ board.sections.push(rootSection);
+ board.items.push(
+ new ItemMockBuilder({ id: "two-by-two" })
+ .addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 2, height: 2 })
+ .build(),
+ new ItemMockBuilder({ id: "one-by-one" })
+ .addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, xOffset: 2, width: 1, height: 1 })
+ .build(),
+ );
+
+ expect(createMobileBoardItems(board, desktopLayoutId)).toMatchObject([
+ { id: "two-by-two", width: 2, height: 2 },
+ { id: "one-by-one", width: 1, height: 1 },
+ ]);
+ });
});
diff --git a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx
index 906abdea6..7a17620ea 100644
--- a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx
+++ b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client";
-import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
+import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
@@ -18,6 +18,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
const t = useI18n();
const board = useRequiredBoard();
+ const currentLayoutId = useCurrentLayout();
const createCategoryAtYOffset = useCallback(
(position: number) => {
@@ -104,7 +105,6 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => {
- const currentLayoutId = getCurrentLayout(board);
const appIds = filterByItemKind(
board.items.filter(
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
@@ -130,7 +130,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
});
break;
}
- }, [category, board, t, openConfirmModal, settings]);
+ }, [category, board, currentLayoutId, t, openConfirmModal, settings]);
return {
addCategoryAbove,
diff --git a/apps/nextjs/src/components/board/sections/dynamic-section.tsx b/apps/nextjs/src/components/board/sections/dynamic-section.tsx
index 8bb0a52c3..c599f607e 100644
--- a/apps/nextjs/src/components/board/sections/dynamic-section.tsx
+++ b/apps/nextjs/src/components/board/sections/dynamic-section.tsx
@@ -1,7 +1,7 @@
import { Badge, Box, Card } from "@mantine/core";
import combineClasses from "clsx";
-import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
+import { useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
@@ -14,7 +14,6 @@ interface Props {
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
- const currentLayoutId = useCurrentLayout();
const options = section.options;
return (
@@ -52,8 +51,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
{options.title}
)}
- {/* Use unique key by layout to reinitialize gridstack */}
-
+
diff --git a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx
index e851e5c2a..1688f6544 100644
--- a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx
+++ b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx
@@ -4,6 +4,8 @@ import type { BoxProps } from "@mantine/core";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
+import { useCurrentLayout } from "@homarr/boards/context";
+
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import { SectionContent } from "../content";
import { SectionProvider } from "../section-context";
@@ -15,6 +17,12 @@ interface Props extends BoxProps {
}
export const GridStack = ({ section, ...props }: Props) => {
+ const currentLayoutId = useCurrentLayout();
+
+ return ;
+};
+
+const GridStackForLayout = ({ section, ...props }: Props) => {
const { items, innerSections } = useSectionItems(section.id);
const itemIds = [...items, ...innerSections].map((item) => item.id);
diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
index b5e468de6..4b3c7d33f 100644
--- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
+++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
@@ -103,6 +103,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
// Updates the react-query state
moveAndResizeItem({
itemId: id,
+ layoutId: currentLayoutId,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -120,6 +121,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
if (type === "section") {
moveAndResizeInnerSection({
innerSectionId: id,
+ layoutId: currentLayoutId,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -136,7 +138,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`);
},
- [moveAndResizeItem, moveAndResizeInnerSection],
+ [currentLayoutId, moveAndResizeItem, moveAndResizeInnerSection],
);
const onAdd = useCallback(
(addedNode: GridStackNode) => {
@@ -149,6 +151,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
// Updates the react-query state
moveItemToSection({
itemId: id,
+ layoutId: currentLayoutId,
sectionId: section.id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
@@ -167,6 +170,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
if (type === "section") {
moveInnerSectionToSection({
innerSectionId: id,
+ layoutId: currentLayoutId,
sectionId: section.id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
@@ -184,7 +188,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`);
},
- [moveItemToSection, moveInnerSectionToSection, section.id],
+ [currentLayoutId, moveItemToSection, moveInnerSectionToSection, section.id],
);
// initialize the gridstack
diff --git a/apps/nextjs/src/components/board/sections/section-actions.tsx b/apps/nextjs/src/components/board/sections/section-actions.tsx
index f61f51552..3db4a7924 100644
--- a/apps/nextjs/src/components/board/sections/section-actions.tsx
+++ b/apps/nextjs/src/components/board/sections/section-actions.tsx
@@ -1,10 +1,10 @@
import { useCallback } from "react";
-import { getCurrentLayout } from "@homarr/boards/context";
import { useUpdateBoard } from "@homarr/boards/updater";
interface MoveAndResizeInnerSection {
innerSectionId: string;
+ layoutId: string;
xOffset: number;
yOffset: number;
width: number;
@@ -12,6 +12,7 @@ interface MoveAndResizeInnerSection {
}
interface MoveInnerSectionToSection {
innerSectionId: string;
+ layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -23,7 +24,7 @@ export const useSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const moveAndResizeInnerSection = useCallback(
- ({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
+ ({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((section) => {
@@ -31,12 +32,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
- const currentLayout = getCurrentLayout(previous);
-
return {
...section,
layouts: section.layouts.map((layout) => {
- if (layout.layoutId !== currentLayout) return layout;
+ if (layout.layoutId !== layoutId) return layout;
return {
...layout,
...positionProps,
@@ -50,7 +49,7 @@ export const useSectionActions = () => {
);
const moveInnerSectionToSection = useCallback(
- ({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
+ ({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
updateBoard((previous) => {
return {
...previous,
@@ -59,12 +58,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
- const currentLayout = getCurrentLayout(previous);
-
return {
...section,
layouts: section.layouts.map((layout) => {
- if (layout.layoutId !== currentLayout) return layout;
+ if (layout.layoutId !== layoutId) return layout;
return {
...layout,
...positionProps,
diff --git a/apps/nextjs/src/components/board/use-mobile-board.tsx b/apps/nextjs/src/components/board/use-mobile-board.tsx
index 84eea8508..8d35a927e 100644
--- a/apps/nextjs/src/components/board/use-mobile-board.tsx
+++ b/apps/nextjs/src/components/board/use-mobile-board.tsx
@@ -4,6 +4,8 @@ import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import { useMediaQuery } from "@mantine/hooks";
+import { useSettings } from "@homarr/settings";
+
export const mobileBoardMediaQuery = "(max-width: 48em)";
const InitialMobileBoardContext = createContext(undefined);
@@ -17,5 +19,8 @@ export const MobileBoardViewportProvider = ({
export const useIsMobileBoard = () => {
const initialIsMobile = useContext(InitialMobileBoardContext);
- return useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
+ const { enableAutomaticMobileLayout } = useSettings();
+ const isMobileViewport = useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
+
+ return enableAutomaticMobileLayout && isMobileViewport;
};
diff --git a/e2e/mobile-board.spec.ts b/e2e/mobile-board.spec.ts
new file mode 100644
index 000000000..ea7b8cddf
--- /dev/null
+++ b/e2e/mobile-board.spec.ts
@@ -0,0 +1,229 @@
+import type { Page } from "@playwright/test";
+import { chromium, devices, expect } from "@playwright/test";
+import { createId } from "@paralleldrive/cuid2";
+import { stringify } from "superjson";
+import { describe, test } from "vitest";
+
+import { eq } from "drizzle-orm";
+import * as sqliteSchema from "../packages/db/schema/sqlite";
+
+import { createHomarrContainer } from "./shared/create-homarr-container";
+import { createSqliteDbFileAsync } from "./shared/e2e-db";
+import type { SqliteDatabase } from "./shared/e2e-db";
+import { seedAdminUserAsync } from "./shared/seed-admin-user";
+
+const credentials = {
+ username: "admin",
+ password: "Comp(exP4sswOrd",
+};
+
+const boardName = "mobile-e2e";
+
+const setAutomaticMobileLayoutAsync = async (page: Page, enabled: boolean) => {
+ await page.goto(new URL("/manage/settings", page.url()).toString());
+ const toggle = page.getByRole("switch", { name: "Use automatic mobile layout" });
+ await expect(toggle).toBeVisible();
+
+ if ((await toggle.isChecked()) !== enabled) {
+ await toggle.setChecked(enabled);
+ await toggle.locator("xpath=ancestor::form").getByRole("button", { name: "Save", exact: true }).click();
+ await expect(page.getByText("Settings saved successfully")).toBeVisible();
+ }
+};
+
+const seedBoardAsync = async (db: SqliteDatabase, userId: string) => {
+ const boardId = createId();
+ const desktopLayoutId = createId();
+ const mobileLayoutId = createId();
+ const sectionId = createId();
+ const notebookId = createId();
+ const clockId = createId();
+
+ await db.insert(sqliteSchema.boards).values({
+ id: boardId,
+ name: boardName,
+ creatorId: userId,
+ });
+ await db.insert(sqliteSchema.layouts).values([
+ {
+ id: desktopLayoutId,
+ name: "Desktop",
+ boardId,
+ columnCount: 12,
+ breakpoint: 1200,
+ },
+ {
+ id: mobileLayoutId,
+ name: "Mobile",
+ boardId,
+ columnCount: 4,
+ breakpoint: 0,
+ },
+ ]);
+ await db.insert(sqliteSchema.sections).values({
+ id: sectionId,
+ boardId,
+ kind: "category",
+ name: "Dashboard",
+ xOffset: 0,
+ yOffset: 0,
+ });
+ await db.insert(sqliteSchema.items).values([
+ {
+ id: notebookId,
+ boardId,
+ kind: "notebook",
+ options: stringify({ content: "Normal notebook content
" }),
+ advancedOptions: stringify({}),
+ },
+ {
+ id: clockId,
+ boardId,
+ kind: "clock",
+ options: stringify({}),
+ advancedOptions: stringify({}),
+ },
+ ]);
+ await db.insert(sqliteSchema.itemLayouts).values([
+ {
+ itemId: notebookId,
+ layoutId: desktopLayoutId,
+ sectionId,
+ xOffset: 0,
+ yOffset: 0,
+ width: 2,
+ height: 2,
+ },
+ {
+ itemId: clockId,
+ layoutId: desktopLayoutId,
+ sectionId,
+ xOffset: 2,
+ yOffset: 0,
+ width: 1,
+ height: 1,
+ },
+ {
+ itemId: notebookId,
+ layoutId: mobileLayoutId,
+ sectionId,
+ xOffset: 0,
+ yOffset: 0,
+ width: 4,
+ height: 4,
+ },
+ {
+ itemId: clockId,
+ layoutId: mobileLayoutId,
+ sectionId,
+ xOffset: 0,
+ yOffset: 4,
+ width: 2,
+ height: 2,
+ },
+ ]);
+ await db
+ .update(sqliteSchema.users)
+ .set({
+ homeBoardId: boardId,
+ mobileHomeBoardId: boardId,
+ completedBoardTour: true,
+ })
+ .where(eq(sqliteSchema.users.id, userId));
+};
+
+describe("Automatic mobile board", () => {
+ test("squeezes unchanged widgets into a read-only two-column grid", async () => {
+ const { db, localMountPath } = await createSqliteDbFileAsync();
+ const { userId } = await seedAdminUserAsync(db, credentials);
+ await seedBoardAsync(db, userId);
+
+ const container = await createHomarrContainer({
+ environment: {
+ AUTH_PROVIDERS: "credentials",
+ },
+ mounts: {
+ "/appdata": localMountPath,
+ },
+ }).start();
+ const baseUrl = `http://${container.getHost()}:${container.getMappedPort(7575)}`;
+ const browser = await chromium.launch();
+ const loginContext = await browser.newContext();
+
+ try {
+ const loginPage = await loginContext.newPage();
+ await loginPage.goto(`${baseUrl}/auth/login`);
+ await loginPage.getByLabel("Username").fill(credentials.username);
+ await loginPage.locator("#password").fill(credentials.password);
+ await loginPage.locator("button[type='submit']").click();
+ await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 });
+ await setAutomaticMobileLayoutAsync(loginPage, true);
+ const storageState = await loginContext.storageState();
+
+ const mobileContext = await browser.newContext({
+ storageState,
+ viewport: { width: 390, height: 844 },
+ userAgent: devices["iPhone 13"].userAgent,
+ hasTouch: true,
+ isMobile: true,
+ });
+ const mobilePage = await mobileContext.newPage();
+ await mobilePage.goto(`${baseUrl}/boards/${boardName}`);
+
+ const mobileBoard = mobilePage.locator("[data-mobile-board]");
+ const items = mobilePage.locator("[data-mobile-board-item]");
+ await expect(mobileBoard).toBeVisible({ timeout: 15_000 });
+ await expect(items).toHaveCount(2);
+ await expect(mobilePage.locator(".grid-stack-item")).toHaveCount(0);
+ await expect(mobilePage.getByText("Normal notebook content")).toBeVisible();
+ await expect(mobilePage.getByRole("button", { name: "Edit item", exact: true })).toHaveCount(0);
+ await expect(mobilePage.getByRole("button", { name: /open widget details/i })).toHaveCount(0);
+ await expect(mobilePage.getByRole("button", { name: /actions for/i })).toHaveCount(0);
+ await expect(mobilePage.locator("[data-mobile-generic-summary], [data-mobile-display-mode]")).toHaveCount(0);
+
+ const metrics = await mobilePage.evaluate(() => {
+ const grid = document.querySelector("[data-mobile-board]");
+ const itemElements = Array.from(document.querySelectorAll("[data-mobile-board-item]"));
+ if (!grid || itemElements.length !== 2) throw new Error("Expected mobile grid and two items");
+
+ const gridStyle = getComputedStyle(grid);
+ const first = itemElements[0]?.getBoundingClientRect();
+ const second = itemElements[1]?.getBoundingClientRect();
+ if (!first || !second) throw new Error("Expected item bounds");
+
+ return {
+ columnCount: gridStyle.gridTemplateColumns.split(" ").filter(Boolean).length,
+ firstToSecondWidthRatio: first.width / second.width,
+ firstToSecondHeightRatio: first.height / second.height,
+ horizontalOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ };
+ });
+
+ expect(metrics.columnCount).toBe(2);
+ expect(metrics.firstToSecondWidthRatio).toBeGreaterThan(1.8);
+ expect(metrics.firstToSecondHeightRatio).toBeGreaterThan(1.8);
+ expect(metrics.horizontalOverflow).toBeLessThanOrEqual(0);
+ await mobileContext.close();
+
+ await setAutomaticMobileLayoutAsync(loginPage, false);
+
+ const legacyContext = await browser.newContext({
+ storageState: await loginContext.storageState(),
+ viewport: { width: 390, height: 844 },
+ userAgent: devices["iPhone 13"].userAgent,
+ hasTouch: true,
+ isMobile: true,
+ });
+ const legacyPage = await legacyContext.newPage();
+ await legacyPage.goto(`${baseUrl}/boards/${boardName}`);
+ await expect(legacyPage.locator("[data-mobile-board]")).toHaveCount(0);
+ await expect(legacyPage.locator(".grid-stack-item")).toHaveCount(2);
+ await expect(legacyPage.getByRole("button", { name: "Edit item", exact: true })).toBeVisible();
+ await legacyContext.close();
+ } finally {
+ await loginContext.close();
+ await browser.close();
+ await container.stop();
+ }
+ }, 180_000);
+});
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index a51313972..ec74a2baf 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -49,6 +49,7 @@ import {
boardDuplicateSchema,
boardRenameSchema,
boardSaveLayoutSchema,
+ boardSaveLayoutsSchema,
boardSavePartialSettingsSchema,
boardSavePermissionsSchema,
boardSaveSchema,
@@ -687,7 +688,7 @@ export const boardRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
- "Set a board's desktop column count and remove legacy responsive layouts. Requires modify permission. REQUIRED: id (board ID), columnCount (1-24)",
+ "Set a board's desktop column count while preserving saved responsive layouts. Requires modify permission. REQUIRED: id (board ID), columnCount (1-24)",
},
})
.input(boardSaveLayoutSchema)
@@ -761,12 +762,8 @@ export const boardRouter = createTRPCRouter({
await transaction
.update(schema.layouts)
- .set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 })
+ .set({ columnCount: input.columnCount })
.where(eq(schema.layouts.id, desktopLayout.id));
-
- await transaction
- .delete(schema.layouts)
- .where(and(eq(schema.layouts.boardId, board.id), not(eq(schema.layouts.id, desktopLayout.id))));
});
},
handleSync(db) {
@@ -811,18 +808,230 @@ export const boardRouter = createTRPCRouter({
transaction
.update(layouts)
- .set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 })
+ .set({ columnCount: input.columnCount })
.where(eq(layouts.id, desktopLayout.id))
.run();
-
- transaction
- .delete(layouts)
- .where(and(eq(layouts.boardId, board.id), not(eq(layouts.id, desktopLayout.id))))
- .run();
});
},
});
}),
+ saveLayouts: protectedProcedure
+ .input(boardSaveLayoutsSchema)
+ .output(boardSaveLayoutsSchema.shape.layouts)
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
+
+ const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
+ const addedLayouts = filterAddedItems(input.layouts, board.layouts);
+ const layoutsToInsert: InferInsertModel[] = [];
+ const itemSectionLayoutsToInsert: InferInsertModel[] = [];
+ const sectionLayoutsToInsert: InferInsertModel[] = [];
+ const savedLayoutIds = new Map();
+
+ for (const addedLayout of addedLayouts) {
+ const layoutId = createId();
+ savedLayoutIds.set(addedLayout.id, layoutId);
+
+ layoutsToInsert.push({
+ id: layoutId,
+ name: addedLayout.name,
+ columnCount: addedLayout.columnCount,
+ breakpoint: addedLayout.breakpoint,
+ boardId: board.id,
+ });
+
+ const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
+ const layoutToClone =
+ sortedLayouts.find((layout) => layout.columnCount >= addedLayout.columnCount) ?? sortedLayouts.at(-1);
+
+ if (!layoutToClone) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a layout" });
+ }
+
+ const updatedBoardLayout = getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: layoutToClone.id,
+ columnCount: layoutToClone.columnCount,
+ },
+ current: {
+ layoutId,
+ columnCount: addedLayout.columnCount,
+ },
+ });
+
+ itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
+ sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
+ }
+
+ const updatedLayouts = filterUpdatedItems(input.layouts, board.layouts);
+ const itemSectionLayoutsToUpdate: InferInsertModel[] = [];
+ const sectionLayoutsToUpdate: InferInsertModel[] = [];
+ const layoutsToUpdate: typeof input.layouts = [];
+
+ for (const updatedLayout of updatedLayouts) {
+ const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id);
+ if (!dbLayout) continue;
+
+ if (dbLayout.columnCount !== updatedLayout.columnCount) {
+ const updatedBoardLayout = getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: dbLayout.id,
+ columnCount: dbLayout.columnCount,
+ },
+ current: {
+ layoutId: dbLayout.id,
+ columnCount: updatedLayout.columnCount,
+ },
+ });
+
+ itemSectionLayoutsToUpdate.push(...updatedBoardLayout.itemSectionLayouts);
+ sectionLayoutsToUpdate.push(...updatedBoardLayout.sectionLayouts);
+ }
+
+ layoutsToUpdate.push(updatedLayout);
+ }
+
+ const removedLayoutIds = filterRemovedItems(input.layouts, board.layouts).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 itemSectionLayout of itemSectionLayoutsToUpdate) {
+ await transaction
+ .update(schema.itemLayouts)
+ .set({
+ height: itemSectionLayout.height,
+ width: itemSectionLayout.width,
+ xOffset: itemSectionLayout.xOffset,
+ yOffset: itemSectionLayout.yOffset,
+ sectionId: itemSectionLayout.sectionId,
+ })
+ .where(
+ and(
+ eq(schema.itemLayouts.itemId, itemSectionLayout.itemId),
+ eq(schema.itemLayouts.layoutId, itemSectionLayout.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,
+ 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 itemSectionLayout of itemSectionLayoutsToUpdate) {
+ transaction
+ .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),
+ ),
+ )
+ .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,
+ breakpoint: layout.breakpoint,
+ })
+ .where(eq(layouts.id, layout.id))
+ .run();
+ }
+
+ if (removedLayoutIds.length > 0) {
+ transaction.delete(layouts).where(inArray(layouts.id, removedLayoutIds)).run();
+ }
+ });
+ },
+ });
+
+ return input.layouts.map((layout) => ({
+ ...layout,
+ id: savedLayoutIds.get(layout.id) ?? layout.id,
+ }));
+ }),
savePartialBoardSettings: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
@@ -1491,16 +1700,16 @@ export const boardRouter = createTRPCRouter({
}
const itemId = createId();
-
- await ctx.db.insert(items).values({
+ const itemRow = {
id: itemId,
boardId: input.boardId,
kind: input.kind,
options: superjson.stringify(input.options),
advancedOptions: emptySuperJSON,
- });
+ };
const layoutRows: (typeof itemLayouts.$inferInsert)[] = [];
+ const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
for (const layout of board.layouts) {
const existingInSection = board.items
@@ -1519,14 +1728,17 @@ export const boardRouter = createTRPCRouter({
}
}
- const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
+ const layoutItemSize = {
+ ...defaultSize,
+ width: Math.min(defaultSize.width, layout.columnCount),
+ };
const fitsAt = (x: number, y: number) => {
- if (x + defaultSize.width > layout.columnCount) return false;
- for (let dy = 0; dy < defaultSize.height; dy++) {
+ if (x + layoutItemSize.width > layout.columnCount) return false;
+ for (let dy = 0; dy < layoutItemSize.height; dy++) {
const row = occupied[y + dy];
if (!row) continue;
- for (let dx = 0; dx < defaultSize.width; dx++) {
+ for (let dx = 0; dx < layoutItemSize.width; dx++) {
if (row[x + dx]) return false;
}
}
@@ -1544,8 +1756,8 @@ export const boardRouter = createTRPCRouter({
layoutId: layout.id,
xOffset: x,
yOffset: y,
- width: defaultSize.width,
- height: defaultSize.height,
+ width: layoutItemSize.width,
+ height: layoutItemSize.height,
});
placed = true;
}
@@ -1560,15 +1772,32 @@ export const boardRouter = createTRPCRouter({
}
}
- if (layoutRows.length > 0) {
- await ctx.db.insert(itemLayouts).values(layoutRows);
- }
+ const integrationRows = input.integrationIds.map((integrationId) => ({ itemId, integrationId }));
- if (input.integrationIds.length > 0) {
- await ctx.db
- .insert(integrationItems)
- .values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
- }
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await db.transaction(async (transaction) => {
+ await transaction.insert(schema.items).values(itemRow);
+ if (layoutRows.length > 0) {
+ await transaction.insert(schema.itemLayouts).values(layoutRows);
+ }
+ if (integrationRows.length > 0) {
+ await transaction.insert(schema.integrationItems).values(integrationRows);
+ }
+ });
+ },
+ handleSync(db) {
+ db.transaction((transaction) => {
+ transaction.insert(items).values(itemRow).run();
+ if (layoutRows.length > 0) {
+ transaction.insert(itemLayouts).values(layoutRows).run();
+ }
+ if (integrationRows.length > 0) {
+ transaction.insert(integrationItems).values(integrationRows).run();
+ }
+ });
+ },
+ });
return { itemId };
}),
@@ -1592,6 +1821,9 @@ const getHomeIdBoardAsync = async (
user: InferSelectModel | null,
deviceType: DeviceType,
) => {
+ // Mobile home-board selection remains device-based because the server does
+ // not know the resolved CSS viewport. Tablet rendering is resolved against
+ // 48em on the client and must not be guessed here.
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
if (!user) {
@@ -1613,8 +1845,7 @@ const getHomeIdBoardAsync = async (
and(
eq(groupMembers.userId, user.id),
not(eq(groups.name, everyoneGroup)),
- not(isNull(groups[settingKey])),
- not(isNull(groups.homeBoardId)),
+ or(not(isNull(groups[settingKey])), not(isNull(groups.homeBoardId))),
),
)
.orderBy(asc(groups.position))
diff --git a/packages/api/src/router/serverSettings.ts b/packages/api/src/router/serverSettings.ts
index 9783e0ae9..2a603c757 100644
--- a/packages/api/src/router/serverSettings.ts
+++ b/packages/api/src/router/serverSettings.ts
@@ -5,10 +5,10 @@ import { and, eq, inArray } from "@homarr/db";
import {
getServerSettingByKeyAsync,
getServerSettingsAsync,
- insertServerSettingByKeyAsync,
+ mergeServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "@homarr/db/queries";
-import { boards, serverSettings } from "@homarr/db/schema";
+import { boards } from "@homarr/db/schema";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { settingsInitSchema } from "@homarr/validation/settings";
@@ -19,6 +19,7 @@ import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
const boardServerSettingsSchema = z.object({
homeBoardId: z.string().nullable(),
mobileHomeBoardId: z.string().nullable(),
+ enableAutomaticMobileLayout: z.boolean(),
enableStatusByDefault: z.boolean(),
forceDisableStatus: z.boolean(),
}) satisfies z.ZodType;
@@ -39,7 +40,7 @@ export const serverSettingsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
- "Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
+ "Get global board defaults, including desktop/mobile home board IDs, automatic mobile layout mode, and status behavior. Requires admin permission",
},
})
.input(z.void())
@@ -54,7 +55,7 @@ export const serverSettingsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
- "Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
+ "Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
},
})
.input(boardServerSettingsUpdateSchema)
@@ -81,17 +82,9 @@ export const serverSettingsRouter = createTRPCRouter({
const current = await getServerSettingByKeyAsync(ctx.db, "board");
const next = { ...current, ...input };
- const existing = await ctx.db.query.serverSettings.findFirst({
- where: eq(serverSettings.settingKey, "board"),
- });
+ await mergeServerSettingByKeyAsync(ctx.db, "board", next, input);
- if (existing) {
- await updateServerSettingByKeyAsync(ctx.db, "board", next);
- } else {
- await insertServerSettingByKeyAsync(ctx.db, "board", next);
- }
-
- return next;
+ return await getServerSettingByKeyAsync(ctx.db, "board");
}),
saveSettings: permissionRequiredProcedure
.requiresPermission("admin")
@@ -103,10 +96,11 @@ export const serverSettingsRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
- await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
+ const next = {
...current,
...input.value,
- } as ServerSettings[keyof ServerSettings]);
+ } as ServerSettings[keyof ServerSettings];
+ await mergeServerSettingByKeyAsync(ctx.db, input.settingsKey, next, input.value);
}),
initSettings: onboardingProcedure
.requiresStep("settings")
diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts
index 479448584..8239f67c1 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 } from "@homarr/db";
+import { and, eq, not } from "@homarr/db";
import {
boardGroupPermissions,
boards,
@@ -516,6 +516,64 @@ describe("getHomeBoard should return home board", () => {
});
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
+ test.each([
+ { deviceType: "mobile", expectedBoard: "mobile-home" },
+ { deviceType: "tablet", expectedBoard: "desktop-home" },
+ ] as const)(
+ "should use the $expectedBoard preference for a $deviceType device",
+ async ({ deviceType, expectedBoard }) => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType, session: defaultSession });
+
+ const mobileBoardProps = await createFullBoardAsync(db, "mobile-home");
+ const desktopBoardProps = await createFullBoardAsync(db, "desktop-home");
+ await db
+ .update(users)
+ .set({
+ homeBoardId: desktopBoardProps.boardId,
+ mobileHomeBoardId: mobileBoardProps.boardId,
+ })
+ .where(eq(users.id, defaultCreatorId));
+
+ // Act
+ const result = await caller.getHomeBoard();
+
+ // Assert
+ expectInputToBeFullBoardWithName(result, {
+ ...(expectedBoard === "mobile-home" ? mobileBoardProps : desktopBoardProps),
+ name: expectedBoard,
+ });
+ },
+ );
+ test("should return a mobile-only group home board", async () => {
+ // Arrange
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: "mobile", session: defaultSession });
+
+ const mobileBoardProps = await createFullBoardAsync(db, "group-mobile-home");
+ const groupId = createId();
+ await db.insert(groups).values({
+ id: groupId,
+ name: "mobile group",
+ position: 1,
+ homeBoardId: null,
+ mobileHomeBoardId: mobileBoardProps.boardId,
+ });
+ await db.insert(groupMembers).values({
+ userId: defaultCreatorId,
+ groupId,
+ });
+
+ // Act
+ const result = await caller.getHomeBoard();
+
+ // Assert
+ expectInputToBeFullBoardWithName(result, {
+ name: "group-mobile-home",
+ ...mobileBoardProps,
+ });
+ });
test("should return global home board when user doesn't have one", async () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
@@ -1324,6 +1382,187 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
);
});
+const createExistingLayout = (id: string) => ({
+ id,
+ name: "Base",
+ columnCount: 10,
+ breakpoint: 0,
+});
+
+const createNewLayout = (columnCount: number) => ({
+ id: createId(),
+ name: "New layout",
+ columnCount,
+ breakpoint: 1400,
+});
+
+describe("saveLayouts should preserve legacy responsive layout editing", () => {
+ test("should add a layout and clone item and dynamic-section placements", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ const assignments = await createItemsAndSectionsAsync(db, {
+ boardId,
+ layoutId,
+ sectionId,
+ });
+ const newLayout = createNewLayout(3);
+
+ const savedLayouts = await caller.saveLayouts({
+ id: boardId,
+ layouts: [createExistingLayout(layoutId), newLayout],
+ });
+
+ const layout = expectToBeDefined(
+ await db.query.layouts.findFirst({
+ where: not(eq(layouts.id, layoutId)),
+ }),
+ );
+ expect(layout).toMatchObject({
+ name: newLayout.name,
+ columnCount: newLayout.columnCount,
+ breakpoint: newLayout.breakpoint,
+ });
+ expect(savedLayouts).toEqual([createExistingLayout(layoutId), { ...newLayout, id: layout.id }]);
+ await expectLayoutForRootLayoutAsync(db, sectionId, layout.id, {
+ ...assignments.inRoot,
+ a: itemId,
+ });
+ await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout.id, assignments.inDynamicSection);
+
+ await caller.saveLayouts({ id: boardId, layouts: savedLayouts });
+ await expect(db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) })).resolves.toHaveLength(2);
+ });
+
+ test("should update layout metadata and positions", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ const assignments = await createItemsAndSectionsAsync(db, {
+ boardId,
+ layoutId,
+ sectionId,
+ });
+
+ await caller.saveLayouts({
+ id: boardId,
+ layouts: [
+ {
+ id: layoutId,
+ name: "Phone",
+ columnCount: 3,
+ breakpoint: 480,
+ },
+ ],
+ });
+
+ await expect(db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).resolves.toMatchObject({
+ name: "Phone",
+ columnCount: 3,
+ breakpoint: 480,
+ });
+ await expectLayoutForRootLayoutAsync(db, sectionId, layoutId, {
+ ...assignments.inRoot,
+ a: itemId,
+ });
+ await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
+ });
+
+ test("should remove layouts omitted from the saved responsive configuration", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+
+ await caller.saveLayouts({
+ id: boardId,
+ layouts: [createNewLayout(12)],
+ });
+
+ await expect(db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).resolves.toBeUndefined();
+ });
+
+ test("should reject duplicate responsive breakpoints", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+
+ await expect(
+ caller.saveLayouts({
+ id: boardId,
+ layouts: [
+ createExistingLayout(layoutId),
+ {
+ ...createNewLayout(4),
+ breakpoint: 0,
+ },
+ ],
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ test("should reject duplicate responsive layout IDs", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const existingLayout = createExistingLayout(layoutId);
+
+ await expect(
+ caller.saveLayouts({
+ id: boardId,
+ layouts: [existingLayout, { ...existingLayout, name: "Phone", breakpoint: 480 }],
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ test("should reject fractional responsive layout values", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+
+ await expect(
+ caller.saveLayouts({
+ id: boardId,
+ layouts: [{ ...createExistingLayout(layoutId), breakpoint: 480.5 }],
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+
+ await expect(
+ caller.saveLayouts({
+ id: boardId,
+ layouts: [{ ...createExistingLayout(layoutId), columnCount: 3.5 }],
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+});
+
+describe("addItem should preserve every saved layout", () => {
+ test("should clamp wide widgets to a retained narrow layout", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = createId();
+ await db.insert(layouts).values({
+ id: mobileLayoutId,
+ name: "Mobile",
+ columnCount: 1,
+ breakpoint: 480,
+ boardId,
+ });
+
+ const { itemId } = await caller.addItem({
+ boardId,
+ kind: "mediaMissing",
+ });
+
+ const savedItemLayouts = await db.query.itemLayouts.findMany({
+ where: eq(itemLayouts.itemId, itemId),
+ });
+ expect(savedItemLayouts).toHaveLength(2);
+ expect(savedItemLayouts).toContainEqual(expect.objectContaining({ layoutId, width: 4 }));
+ expect(savedItemLayouts).toContainEqual(expect.objectContaining({ layoutId: mobileLayoutId, width: 1 }));
+ });
+});
+
describe("saveLayout should save the desktop layout", () => {
test("should update position of items when column count changes", async () => {
// Arrange
@@ -1349,7 +1588,7 @@ describe("saveLayout should save the desktop layout", () => {
});
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
});
- test("should keep the desktop layout and remove legacy responsive layouts", async () => {
+ test("should preserve legacy layouts and desktop metadata", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
@@ -1376,15 +1615,18 @@ describe("saveLayout should save the desktop layout", () => {
// Act
await caller.saveLayout({
id: boardId,
- columnCount: 16,
+ columnCount: 14,
});
// Assert
const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
- expect(savedLayouts).toEqual([
- expect.objectContaining({ id: desktopLayoutId, name: "Base", columnCount: 16, breakpoint: 0 }),
- ]);
- expect(savedLayouts.some((layout) => layout.id === layoutId)).toBe(false);
+ expect(savedLayouts).toHaveLength(2);
+ expect(savedLayouts).toContainEqual(
+ expect.objectContaining({ id: layoutId, name: "Base", columnCount: 10, breakpoint: 0 }),
+ );
+ expect(savedLayouts).toContainEqual(
+ expect.objectContaining({ id: desktopLayoutId, name: "Large", columnCount: 14, breakpoint: 1200 }),
+ );
});
test("should fail when board not found", async () => {
// Arrange
@@ -1427,9 +1669,12 @@ const expectInputToBeFullBoardWithName = (
};
const createFullBoardAsync = async (db: Database, name: string) => {
- await db.insert(users).values({
- id: defaultCreatorId,
- });
+ await db
+ .insert(users)
+ .values({
+ id: defaultCreatorId,
+ })
+ .onConflictDoNothing();
const boardId = createId();
await db.insert(boards).values({
diff --git a/packages/api/src/router/test/serverSettings.spec.ts b/packages/api/src/router/test/serverSettings.spec.ts
index c045b1288..df1e917cf 100644
--- a/packages/api/src/router/test/serverSettings.spec.ts
+++ b/packages/api/src/router/test/serverSettings.spec.ts
@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
-import { boards, serverSettings } from "@homarr/db/schema";
+import { boards, layouts, serverSettings } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
@@ -56,6 +56,33 @@ describe("getAll server settings", () => {
});
describe("saveSettings", () => {
+ test("saveSettings should persist settings when defaults are not seeded", async () => {
+ const db = createDb();
+ const caller = serverSettingsRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ await caller.saveSettings({
+ settingsKey: "board",
+ value: {
+ enableAutomaticMobileLayout: false,
+ },
+ });
+
+ const dbSettings = await db.select().from(serverSettings);
+ expect(dbSettings).toStrictEqual([
+ {
+ settingKey: "board",
+ value: stringify({
+ ...defaultServerSettings.board,
+ enableAutomaticMobileLayout: false,
+ }),
+ },
+ ]);
+ });
+
test("saveSettings should update settings and return true when it updated only one", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
@@ -89,6 +116,33 @@ describe("saveSettings", () => {
},
]);
});
+
+ test("saveSettings should preserve concurrent partial updates", async () => {
+ const db = createDb();
+ const caller = serverSettingsRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ await Promise.all([
+ caller.saveSettings({
+ settingsKey: "analytics",
+ value: { enableGeneral: false },
+ }),
+ caller.saveSettings({
+ settingsKey: "analytics",
+ value: { instanceId: "concurrent-instance" },
+ }),
+ ]);
+
+ await expect(caller.getAll()).resolves.toMatchObject({
+ analytics: {
+ enableGeneral: false,
+ instanceId: "concurrent-instance",
+ },
+ });
+ });
});
describe("board settings API", () => {
@@ -103,6 +157,36 @@ describe("board settings API", () => {
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
});
+ test("getBoardSettings should backfill the automatic layout default for legacy values", async () => {
+ const db = createDb();
+ const caller = serverSettingsRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+ const boardId = createId();
+ await db.insert(boards).values({ id: boardId, name: "legacy", isPublic: false });
+ await db.insert(layouts).values([
+ { id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
+ { id: createId(), boardId, name: "Mobile", columnCount: 4, breakpoint: 480 },
+ ]);
+ const legacyBoardSettings = {
+ homeBoardId: defaultServerSettings.board.homeBoardId,
+ mobileHomeBoardId: defaultServerSettings.board.mobileHomeBoardId,
+ enableStatusByDefault: defaultServerSettings.board.enableStatusByDefault,
+ forceDisableStatus: defaultServerSettings.board.forceDisableStatus,
+ };
+ await db.insert(serverSettings).values({
+ settingKey: "board",
+ value: stringify(legacyBoardSettings),
+ });
+
+ await expect(caller.getBoardSettings()).resolves.toStrictEqual({
+ ...defaultServerSettings.board,
+ enableAutomaticMobileLayout: false,
+ });
+ });
+
test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
@@ -145,6 +229,7 @@ describe("board settings API", () => {
await caller.updateBoardSettings({ homeBoardId: boardId });
const result = await caller.updateBoardSettings({
mobileHomeBoardId: boardId,
+ enableAutomaticMobileLayout: false,
enableStatusByDefault: false,
});
@@ -152,6 +237,7 @@ describe("board settings API", () => {
...defaultServerSettings.board,
homeBoardId: boardId,
mobileHomeBoardId: boardId,
+ enableAutomaticMobileLayout: false,
enableStatusByDefault: false,
});
const dbSettings = await db.select().from(serverSettings);
@@ -163,6 +249,25 @@ describe("board settings API", () => {
]);
});
+ test("updateBoardSettings should preserve concurrent partial updates", async () => {
+ const db = createDb();
+ const caller = serverSettingsRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSession,
+ });
+
+ await Promise.all([
+ caller.updateBoardSettings({ forceDisableStatus: true }),
+ caller.updateBoardSettings({ enableStatusByDefault: false }),
+ ]);
+
+ await expect(caller.getBoardSettings()).resolves.toMatchObject({
+ forceDisableStatus: true,
+ enableStatusByDefault: false,
+ });
+ });
+
test("updateBoardSettings should reject private home boards", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts
index e39e0e4ab..94da929ac 100644
--- a/packages/api/src/test/mcp.spec.ts
+++ b/packages/api/src/test/mcp.spec.ts
@@ -33,6 +33,7 @@ test("MCP tools should contain expected procedures", () => {
expect(toolNames).toContain("app_byId");
expect(toolNames).toContain("app_create");
expect(toolNames).toContain("board_savePartialBoardSettings");
+ expect(toolNames).toContain("board_saveLayout");
expect(toolNames).toContain("board_duplicateBoard");
expect(toolNames).toContain("info_getInfo");
expect(toolNames).toContain("invite_getAll");
diff --git a/packages/boards/package.json b/packages/boards/package.json
index b9fd9a40d..353803c0e 100644
--- a/packages/boards/package.json
+++ b/packages/boards/package.json
@@ -24,6 +24,7 @@
},
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
+ "@homarr/settings": "workspace:^0.1.0",
"react": "catalog:",
"react-dom": "catalog:"
},
diff --git a/packages/boards/src/context.spec.ts b/packages/boards/src/context.spec.ts
new file mode 100644
index 000000000..64a64732e
--- /dev/null
+++ b/packages/boards/src/context.spec.ts
@@ -0,0 +1,47 @@
+import { describe, expect, test } from "vitest";
+
+import type { RouterOutputs } from "@homarr/api";
+
+import { getBoardLayouts, getCurrentLayout, getDesktopLayout } from "./context";
+
+const createBoard = () =>
+ ({
+ layouts: [
+ { id: "small", name: "Small", columnCount: 4, breakpoint: 0 },
+ { id: "medium", name: "Medium", columnCount: 8, breakpoint: 768 },
+ { id: "large", name: "Large", columnCount: 12, breakpoint: 1200 },
+ ],
+ }) as RouterOutputs["board"]["getBoardByName"];
+
+describe("board layout selection", () => {
+ test("automatic mode always uses the desktop layout", () => {
+ const board = createBoard();
+
+ expect(getDesktopLayout(board).id).toBe("large");
+ expect(getCurrentLayout(board, true, 390)).toBe("large");
+ });
+
+ test.each([
+ [390, "small"],
+ [768, "medium"],
+ [1024, "medium"],
+ [1440, "large"],
+ ])("legacy mode selects the matching responsive layout at %spx", (viewportWidth, expectedLayoutId) => {
+ expect(getCurrentLayout(createBoard(), false, viewportWidth)).toBe(expectedLayoutId);
+ });
+
+ test("legacy mode falls back to the desktop layout before the client viewport is known", () => {
+ expect(getCurrentLayout(createBoard(), false, undefined)).toBe("large");
+ });
+
+ test("rejects boards without a layout", () => {
+ const board = { ...createBoard(), layouts: [] };
+
+ expect(() => getDesktopLayout(board)).toThrow("Board must have a layout");
+ expect(() => getCurrentLayout(board, false, 390)).toThrow("Board must have a layout");
+ });
+
+ test("keeps every saved layout available for reversible mutations", () => {
+ expect(getBoardLayouts(createBoard())).toEqual(["small", "medium", "large"]);
+ });
+});
diff --git a/packages/boards/src/context.tsx b/packages/boards/src/context.tsx
index b76ea03a6..fea125673 100644
--- a/packages/boards/src/context.tsx
+++ b/packages/boards/src/context.tsx
@@ -1,12 +1,13 @@
"use client";
import type { PropsWithChildren } from "react";
-import { createContext, useContext, useEffect } from "react";
+import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getActiveQueryCacheBoardId, setActiveQueryCacheBoardId } from "@homarr/api/query-cache";
+import { useSettings } from "@homarr/settings";
import { updateBoardName } from "./updater";
@@ -73,10 +74,6 @@ export const useOptionalBoard = () => {
return context?.board ?? null;
};
-export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => {
- return getDesktopLayout(board).id;
-};
-
export const getDesktopLayout = (board: RouterOutputs["board"]["getBoardByName"]) => {
const layout = board.layouts
.toSorted(
@@ -91,9 +88,57 @@ export const getDesktopLayout = (board: RouterOutputs["board"]["getBoardByName"]
return layout;
};
-export const useCurrentLayout = () => getCurrentLayout(useRequiredBoard());
+export const getCurrentLayout = (
+ board: RouterOutputs["board"]["getBoardByName"],
+ enableAutomaticMobileLayout: boolean,
+ viewportWidth?: number,
+) => {
+ if (enableAutomaticMobileLayout || viewportWidth === undefined) {
+ return getDesktopLayout(board).id;
+ }
-export const getBoardLayouts = (board: RouterOutputs["board"]["getBoardByName"]) => [getCurrentLayout(board)];
+ const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
+ const layout = sortedLayouts.find((candidate) => candidate.breakpoint <= viewportWidth) ?? sortedLayouts.at(-1);
+
+ if (!layout) {
+ throw new Error("Board must have a layout");
+ }
+
+ return layout.id;
+};
+
+export const useCurrentLayout = () => {
+ const board = useRequiredBoard();
+ const { enableAutomaticMobileLayout } = useSettings();
+ const [currentLayout, setCurrentLayout] = useState(() =>
+ getCurrentLayout(
+ board,
+ enableAutomaticMobileLayout,
+ !enableAutomaticMobileLayout && typeof window !== "undefined" ? window.innerWidth : undefined,
+ ),
+ );
+
+ const updateCurrentLayout = useCallback(() => {
+ setCurrentLayout(getCurrentLayout(board, enableAutomaticMobileLayout, window.innerWidth));
+ }, [board, enableAutomaticMobileLayout]);
+
+ useEffect(() => {
+ updateCurrentLayout();
+
+ if (enableAutomaticMobileLayout) return;
+
+ window.addEventListener("resize", updateCurrentLayout);
+
+ return () => {
+ window.removeEventListener("resize", updateCurrentLayout);
+ };
+ }, [enableAutomaticMobileLayout, updateCurrentLayout]);
+
+ return currentLayout;
+};
+
+export const getBoardLayouts = (board: RouterOutputs["board"]["getBoardByName"]) =>
+ board.layouts.map((layout) => layout.id);
export const useLayouts = () => {
const board = useRequiredBoard();
diff --git a/packages/db/collection.ts b/packages/db/collection.ts
index 00e5aba18..7893435fd 100644
--- a/packages/db/collection.ts
+++ b/packages/db/collection.ts
@@ -4,7 +4,7 @@ import { objectEntries } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import type { HomarrDatabase, HomarrDatabaseMysql, HomarrDatabasePostgresql } from "./driver";
-import * as schema from "./schema";
+import { schema } from "./schema";
type TableKey = {
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
diff --git a/packages/db/migrations/seed.spec.ts b/packages/db/migrations/seed.spec.ts
new file mode 100644
index 000000000..5346026c0
--- /dev/null
+++ b/packages/db/migrations/seed.spec.ts
@@ -0,0 +1,115 @@
+import { stringify } from "superjson";
+import { describe, expect, test } from "vitest";
+
+import { createId } from "@homarr/common";
+import { eq } from "@homarr/db";
+import {
+ getServerSettingByKeyAsync,
+ getServerSettingsAsync,
+ shouldEnableAutomaticMobileLayoutForUpgrade,
+} from "@homarr/db/queries";
+import { boards, layouts, serverSettings } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+import { defaultServerSettings } from "@homarr/server-settings";
+
+import { seedServerSettingsAsync } from "./seed";
+
+describe("automatic mobile layout upgrade default", () => {
+ test("enables automatic mode for a new instance", () => {
+ expect(shouldEnableAutomaticMobileLayoutForUpgrade([])).toBe(true);
+ });
+
+ test("enables automatic mode when every existing board has only its canonical layout", () => {
+ expect(
+ shouldEnableAutomaticMobileLayoutForUpgrade([
+ { boardId: "first", name: "Base", breakpoint: 0 },
+ { boardId: "second", name: "Base", breakpoint: 0 },
+ ]),
+ ).toBe(true);
+ });
+
+ test("preserves legacy mode when a board has multiple responsive layouts", () => {
+ expect(
+ shouldEnableAutomaticMobileLayoutForUpgrade([
+ { boardId: "board", name: "Base", breakpoint: 0 },
+ { boardId: "board", name: "Mobile", breakpoint: 480 },
+ ]),
+ ).toBe(false);
+ });
+
+ test("preserves legacy mode when a board has duplicate canonical layouts", () => {
+ expect(
+ shouldEnableAutomaticMobileLayoutForUpgrade([
+ { boardId: "board", name: "Base", breakpoint: 0 },
+ { boardId: "board", name: "Base", breakpoint: 0 },
+ ]),
+ ).toBe(false);
+ });
+
+ test.each([
+ { boardId: "board", name: "Phone", breakpoint: 0 },
+ { boardId: "board", name: "Base", breakpoint: 480 },
+ ])("preserves legacy mode for a noncanonical saved layout", (existingLayout) => {
+ expect(shouldEnableAutomaticMobileLayoutForUpgrade([existingLayout])).toBe(false);
+ });
+
+ test("uses the upgrade heuristic when migrations and seeding are skipped", async () => {
+ const db = createDb();
+ const boardId = createId();
+ await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false });
+ await db.insert(layouts).values([
+ { id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
+ { id: createId(), boardId, name: "Phone", columnCount: 4, breakpoint: 480 },
+ ]);
+
+ await expect(getServerSettingByKeyAsync(db, "board")).resolves.toMatchObject({
+ enableAutomaticMobileLayout: false,
+ });
+ await expect(getServerSettingsAsync(db)).resolves.toMatchObject({
+ board: {
+ enableAutomaticMobileLayout: false,
+ },
+ });
+ });
+
+ test("backfills legacy responsive instances once without overriding an explicit choice", async () => {
+ const db = createDb();
+ const boardId = createId();
+ await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false });
+ await db.insert(layouts).values([
+ { id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
+ { id: createId(), boardId, name: "Phone", columnCount: 4, breakpoint: 480 },
+ ]);
+ await db.insert(serverSettings).values({
+ settingKey: "board",
+ value: stringify({
+ homeBoardId: null,
+ mobileHomeBoardId: null,
+ enableStatusByDefault: true,
+ forceDisableStatus: false,
+ }),
+ });
+
+ await seedServerSettingsAsync(db);
+ await seedServerSettingsAsync(db);
+
+ await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual({
+ ...defaultServerSettings.board,
+ enableAutomaticMobileLayout: false,
+ });
+
+ await db
+ .update(serverSettings)
+ .set({
+ value: stringify({
+ ...defaultServerSettings.board,
+ enableAutomaticMobileLayout: true,
+ }),
+ })
+ .where(eq(serverSettings.settingKey, "board"));
+
+ await seedServerSettingsAsync(db);
+
+ await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board);
+ });
+});
diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts
index 6ec906cec..a7d11d52b 100644
--- a/packages/db/migrations/seed.ts
+++ b/packages/db/migrations/seed.ts
@@ -21,6 +21,7 @@ import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
import {
getServerSettingByKeyAsync,
insertServerSettingByKeyAsync,
+ shouldEnableAutomaticMobileLayoutForUpgrade,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
@@ -245,19 +246,33 @@ const seedDefaultSearchEnginesAsync = async (db: Database) => {
console.log("Set Homarr docs as the default search engine");
};
-const seedServerSettingsAsync = async (db: Database) => {
+export const seedServerSettingsAsync = async (db: Database) => {
const serverSettingsData = await db.query.serverSettings.findMany();
+ const existingLayouts = await db.query.layouts.findMany({
+ columns: {
+ boardId: true,
+ name: true,
+ breakpoint: true,
+ },
+ });
+ const defaultsForThisInstance = {
+ ...defaultServerSettings,
+ board: {
+ ...defaultServerSettings.board,
+ enableAutomaticMobileLayout: shouldEnableAutomaticMobileLayoutForUpgrade(existingLayouts),
+ },
+ };
for (const settingsKey of defaultServerSettingsKeys) {
const currentDbEntry = serverSettingsData.find((setting) => setting.settingKey === settingsKey);
if (!currentDbEntry) {
- await insertServerSettingByKeyAsync(db, settingsKey, defaultServerSettings[settingsKey]);
+ await insertServerSettingByKeyAsync(db, settingsKey, defaultsForThisInstance[settingsKey]);
console.log(`Created serverSetting through seed key=${settingsKey}`);
continue;
}
- const currentSettings = await getServerSettingByKeyAsync(db, settingsKey);
- const defaultSettings = defaultServerSettings[settingsKey];
+ const currentSettings = SuperJSON.parse<(typeof defaultServerSettings)[typeof settingsKey]>(currentDbEntry.value);
+ const defaultSettings = defaultsForThisInstance[settingsKey];
const missingKeys = objectKeys(defaultSettings).filter((key) => !(key in currentSettings));
if (missingKeys.length === 0) {
diff --git a/packages/db/queries/server-setting.ts b/packages/db/queries/server-setting.ts
index 371b18f29..18f967582 100644
--- a/packages/db/queries/server-setting.ts
+++ b/packages/db/queries/server-setting.ts
@@ -1,29 +1,80 @@
-import SuperJSON from "superjson";
+import { parse, stringify } from "superjson";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
-import { eq } from "..";
+import { eq, sql } from "..";
+import { isMysql, isPostgresql } from "../collection";
+import type { HomarrDatabaseMysql, HomarrDatabasePostgresql } from "../driver";
import { serverSettings } from "../schema";
+import { serverSettings as mysqlServerSettings } from "../schema/mysql";
+import { serverSettings as postgresqlServerSettings } from "../schema/postgresql";
+
+interface ExistingBoardLayout {
+ boardId: string;
+ name: string;
+ breakpoint: number;
+}
+
+export const shouldEnableAutomaticMobileLayoutForUpgrade = (existingLayouts: ExistingBoardLayout[]) => {
+ const layoutCounts = new Map();
+
+ for (const layout of existingLayouts) {
+ if (layout.name !== "Base" || layout.breakpoint !== 0) {
+ return false;
+ }
+
+ const nextCount = (layoutCounts.get(layout.boardId) ?? 0) + 1;
+ if (nextCount > 1) {
+ return false;
+ }
+ layoutCounts.set(layout.boardId, nextCount);
+ }
+
+ return true;
+};
+
+const getAutomaticMobileLayoutUpgradeDefaultAsync = async (db: Database) => {
+ const existingLayouts = await db.query.layouts.findMany({
+ columns: {
+ boardId: true,
+ name: true,
+ breakpoint: true,
+ },
+ });
+
+ return shouldEnableAutomaticMobileLayoutForUpgrade(existingLayouts);
+};
export const getServerSettingsAsync = async (db: Database) => {
const settings = await db.query.serverSettings.findMany();
+ let parsedBoardSettings: Partial = {};
- return defaultServerSettingsKeys.reduce((acc, settingKey) => {
- const setting = settings.find((setting) => setting.settingKey === settingKey);
+ const mergedSettings = defaultServerSettingsKeys.reduce((acc, settingKey) => {
+ const setting = settings.find((candidate) => candidate.settingKey === settingKey);
if (!setting) {
// Typescript is not happy because the key is a union and it does not know that they are the same
- acc[settingKey] = defaultServerSettings[settingKey] as never;
+ acc[settingKey] = { ...defaultServerSettings[settingKey] } as never;
return acc;
}
+ const persistedSettings = parse>(setting.value);
+ if (settingKey === "board") {
+ parsedBoardSettings = persistedSettings as Partial;
+ }
acc[settingKey] = {
...defaultServerSettings[settingKey],
- ...SuperJSON.parse(setting.value),
- };
+ ...persistedSettings,
+ } as never;
return acc;
}, {} as ServerSettings);
+
+ if (typeof parsedBoardSettings.enableAutomaticMobileLayout !== "boolean") {
+ mergedSettings.board.enableAutomaticMobileLayout = await getAutomaticMobileLayoutUpgradeDefaultAsync(db);
+ }
+
+ return mergedSettings;
};
export const getServerSettingByKeyAsync = async (db: Database, key: TKey) => {
@@ -32,10 +83,31 @@ export const getServerSettingByKeyAsync = async (dbSettings.value);
+ const persistedSettings = parse>(dbSettings.value);
+ const mergedSettings = {
+ ...defaultServerSettings[key],
+ ...persistedSettings,
+ } as ServerSettings[TKey];
+
+ if (
+ key === "board" &&
+ typeof (persistedSettings as Partial).enableAutomaticMobileLayout !== "boolean"
+ ) {
+ (mergedSettings as ServerSettings["board"]).enableAutomaticMobileLayout =
+ await getAutomaticMobileLayoutUpgradeDefaultAsync(db);
+ }
+
+ return mergedSettings;
};
export const updateServerSettingByKeyAsync = async (
@@ -46,7 +118,7 @@ export const updateServerSettingByKeyAsync = async {
await db.insert(serverSettings).values({
settingKey: key,
- value: SuperJSON.stringify(value),
+ value: stringify(value),
});
};
+
+export const mergeServerSettingByKeyAsync = async (
+ db: Database,
+ key: TKey,
+ value: ServerSettings[TKey],
+ patch: Partial,
+) => {
+ const serializedValue = stringify(value);
+ const serializedPatch = stringify(patch);
+
+ if (isMysql()) {
+ await (db as unknown as HomarrDatabaseMysql)
+ .insert(mysqlServerSettings)
+ .values({ settingKey: key, value: serializedValue })
+ .onDuplicateKeyUpdate({
+ set: {
+ value: sql`JSON_MERGE_PATCH(${mysqlServerSettings.value}, ${serializedPatch})`,
+ },
+ });
+ return;
+ }
+
+ if (isPostgresql()) {
+ await (db as unknown as HomarrDatabasePostgresql)
+ .insert(postgresqlServerSettings)
+ .values({ settingKey: key, value: serializedValue })
+ .onConflictDoUpdate({
+ target: postgresqlServerSettings.settingKey,
+ set: {
+ value: sql`jsonb_set(
+ ${postgresqlServerSettings.value}::jsonb,
+ '{json}',
+ (${postgresqlServerSettings.value}::jsonb -> 'json') || (${serializedPatch}::jsonb -> 'json'),
+ true
+ )::text`,
+ },
+ });
+ return;
+ }
+
+ await db
+ .insert(serverSettings)
+ .values({ settingKey: key, value: serializedValue })
+ .onConflictDoUpdate({
+ target: serverSettings.settingKey,
+ set: {
+ value: sql`json_patch(${serverSettings.value}, ${serializedPatch})`,
+ },
+ });
+};
diff --git a/packages/db/test/schema.spec.ts b/packages/db/test/schema.spec.ts
index 246219547..6606636e1 100644
--- a/packages/db/test/schema.spec.ts
+++ b/packages/db/test/schema.spec.ts
@@ -11,6 +11,9 @@ import * as mysqlSchema from "../schema/mysql";
import * as postgresqlSchema from "../schema/postgresql";
import * as sqliteSchema from "../schema/sqlite";
+const mysqlTables = { ...mysqlSchema };
+const postgresqlTables = { ...postgresqlSchema };
+
// We need the following three types as there is currently no support for Buffer in mysql & pg and
// so we use a custom type which results in the config beeing different
type FixedMysqlConfig = {
@@ -63,7 +66,7 @@ test("schemas should match", () => {
if (!("uniqueName" in sqliteColumn)) return;
if (!("primary" in sqliteColumn)) return;
- const mysqlTable = mysqlSchema[tableName];
+ const mysqlTable = mysqlTables[tableName];
const mysqlColumn = mysqlTable[columnName as keyof typeof mysqlTable] as object;
if (!("isUnique" in mysqlColumn)) return;
@@ -84,7 +87,7 @@ test("schemas should match", () => {
).toEqual(mysqlColumn.primary);
});
- const mysqlTable = mysqlSchema[tableName];
+ const mysqlTable = mysqlTables[tableName];
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
| SqliteForeignKey[]
| undefined;
@@ -144,7 +147,7 @@ test("schemas should match for postgresql", () => {
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
// keys of sqliteSchema and postgresqlSchema are the same, so we can safely use tableName as key
// skipcq: JS-E1007
- const postgresqlTable = postgresqlSchema[tableName];
+ const postgresqlTable = postgresqlTables[tableName];
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
if (!("isUnique" in sqliteColumn)) return;
if (!("uniqueName" in sqliteColumn)) return;
diff --git a/packages/server-settings/src/index.ts b/packages/server-settings/src/index.ts
index a8a52ea99..4e94389fd 100644
--- a/packages/server-settings/src/index.ts
+++ b/packages/server-settings/src/index.ts
@@ -27,6 +27,7 @@ export const defaultServerSettings = {
board: {
homeBoardId: null as string | null,
mobileHomeBoardId: null as string | null,
+ enableAutomaticMobileLayout: true,
enableStatusByDefault: true,
forceDisableStatus: false,
},
diff --git a/packages/settings/src/creator.ts b/packages/settings/src/creator.ts
index ee7dfaf6e..1333ff220 100644
--- a/packages/settings/src/creator.ts
+++ b/packages/settings/src/creator.ts
@@ -12,14 +12,14 @@ export type SettingsContextProps = Pick<
| "pingIconsEnabled"
| "enableRightClickOnWidgets"
> &
- Pick &
+ Pick &
Pick;
export interface PublicServerSettings {
search: Pick;
board: Pick<
ServerSettings["board"],
- "homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
+ "homeBoardId" | "mobileHomeBoardId" | "enableAutomaticMobileLayout" | "enableStatusByDefault" | "forceDisableStatus"
>;
user: Pick;
}
@@ -51,6 +51,7 @@ export const createSettings = ({
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: user?.pingIconsEnabled ?? false,
enableRightClickOnWidgets: user?.enableRightClickOnWidgets ?? true,
+ enableAutomaticMobileLayout: serverSettings.board.enableAutomaticMobileLayout,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
enableGravatar: serverSettings.user.enableGravatar,
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 806f93d1f..8c8dd4b0a 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -4391,7 +4391,15 @@
"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."
+ "automaticDescription": "On mobile, the normal widgets are arranged in two columns and cannot be moved or resized. Categories and dynamic sections are flattened into the item list."
+ },
+ "responsive": {
+ "title": "Responsive layouts",
+ "description": "Automatic mobile layout is disabled for this server. Configure the saved layouts selected at each viewport breakpoint; these layouts can also be edited directly on mobile.",
+ "defaultName": "New layout",
+ "action": {
+ "add": "Add layout"
+ }
}
},
"background": {
@@ -4841,6 +4849,13 @@
"mobileLabel": "Global mobile board",
"description": "Only public boards are available for selection"
},
+ "mobileLayout": {
+ "title": "Mobile layout",
+ "enable": {
+ "label": "Use automatic mobile layout",
+ "description": "Squeeze every board into a read-only two-column grid on phones and small tablets without changing widget content. Turn this off to use saved responsive layouts and mobile layout editing. Saved layouts are kept while automatic mode is enabled."
+ }
+ },
"status": {
"title": "App status",
"enableStatusByDefault": {
diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts
index e16b4eb11..c68085bf0 100644
--- a/packages/validation/src/board.ts
+++ b/packages/validation/src/board.ts
@@ -24,7 +24,7 @@ export const boardNameSchema = z
.min(1)
.max(255)
.regex(/^[A-Za-z0-9-_]*$/);
-export const boardColumnCountSchema = z.number().min(1).max(24);
+export const boardColumnCountSchema = z.number().int().min(1).max(24);
export const boardByNameSchema = z.object({
name: boardNameSchema,
@@ -75,6 +75,46 @@ export const boardSaveLayoutSchema = z.object({
columnCount: boardColumnCountSchema,
});
+const responsiveBoardLayoutsSchema = z
+ .array(
+ z.object({
+ id: z.string(),
+ name: z.string().trim().nonempty().max(32),
+ columnCount: boardColumnCountSchema,
+ breakpoint: z.number().int().min(0).max(32767),
+ }),
+ )
+ .min(1)
+ .superRefine((layouts, context) => {
+ const seenBreakpoints = new Set();
+ const seenIds = new Set();
+
+ layouts.forEach((layout, index) => {
+ if (seenIds.has(layout.id)) {
+ context.addIssue({
+ code: "custom",
+ message: "Each responsive layout must have a unique ID",
+ path: [index, "id"],
+ });
+ }
+ seenIds.add(layout.id);
+
+ if (seenBreakpoints.has(layout.breakpoint)) {
+ context.addIssue({
+ code: "custom",
+ message: "Each responsive layout must have a unique breakpoint",
+ path: [index, "breakpoint"],
+ });
+ }
+ seenBreakpoints.add(layout.breakpoint);
+ });
+ });
+
+export const boardSaveLayoutsSchema = z.object({
+ id: z.string(),
+ layouts: responsiveBoardLayoutsSchema,
+});
+
export const boardSaveSchema = z.object({
id: z.string(),
sections: z.array(sectionSchema),
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f7fafc619..8952788f2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1427,6 +1427,9 @@ importers:
'@homarr/api':
specifier: workspace:^0.1.0
version: link:../api
+ '@homarr/settings':
+ specifier: workspace:^0.1.0
+ version: link:../settings
react:
specifier: 'catalog:'
version: 19.2.7