fix: keep mobile board widgets unchanged

This commit is contained in:
ajnart committed 2026-07-29 20:21:59 +02:00
1 parent e2732dce2c
commit b5ffefe707
43 files changed
+1669 -181

No files matched your search

+1 -1
View File
@@ -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.
+68 -28
View File
@@ -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.
![](./img/create-board-modal.png)
### 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:
![](./img/navigate-to-settings.png)
@@ -49,7 +54,9 @@ Alternatively, you can also navigate from the board page itself using the cog ic
![](./img/navigate-to-settings-2.png)
### General
#### Page title
This is the title that will be visible at the top left in the navigation bar:
![](./img/page-title.png)
@@ -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.
<img src={layoutLargeImage} height={256} />
#### 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):
![](./img/background-repeat.png)
### 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.
<img src={iconColorImage} height={100} />
<img src={iconColorImage} height={100} />
#### Item radius
Change the radius of your items / categories.
<img src={itemRadiusImage} height={100} />
<img src={itemRadiusImage} height={100} />
### 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
![](./img/duplicate-board-modal.png)
## 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:
![](./img/home-board-flag.png)
### For yourself
To set the home board yourself, the easiest option is to set it directly in the list of boards:
![](./img/set-as-home-board-menu.png)
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`` \
![](./img/set-home-board-other-user.png)
3. Select the desired home board via the dropdown and click on `Save` \
![](./img/set-home-board-other-user.png)
### 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`` \
![](./img/set-home-board-user-group.png)
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` \
![](./img/set-home-board-user-group.png)
### 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`` \
![](./img/set-home-board-global.png)
1. Navigate to the settings: `Management` > `Settings` > `Boards`
2. Select the desired home board via the dropdown and click on `Save` \
![](./img/set-home-board-global.png)
:::note
For the global home board you can only select boards that are public.
+26 -4
View File
@@ -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.
You can set the default language for all users that have not set a language themselves.
@@ -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 ? (
<AutomaticLayoutSettingsContent board={board} />
) : (
<ResponsiveLayoutSettingsContent board={board} />
);
const AutomaticLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
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")}
</Text>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} />
<Slider
mt="xs"
min={1}
max={24}
step={1}
thumbLabel={t("layout.field.columnCount.label")}
{...form.getInputProps("columnCount")}
/>
</Input.Wrapper>
</Stack>
</Fieldset>
<Text c="dimmed" size="sm">
{t("board.setting.section.layout.mobile.description")}
{t("board.setting.section.layout.mobile.automaticDescription")}
</Text>
<Group justify="end">
@@ -63,3 +93,124 @@ export const LayoutSettingsContent = ({ board }: Props) => {
</form>
);
};
const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
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 (
<form
onSubmit={form.onSubmit((values) => {
saveLayouts({
id: board.id,
...values,
});
})}
>
<Stack>
<Text c="dimmed" size="sm">
{t("board.setting.section.layout.responsive.description")}
</Text>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
<Button
variant="subtle"
onClick={() => {
form.setValues({
layouts: [
...form.values.layouts,
{
id: createId(),
name: t("board.setting.section.layout.responsive.defaultName"),
columnCount: 10,
breakpoint: getNextAvailableBreakpoint(form.values.layouts),
},
],
});
}}
>
{t("board.setting.section.layout.responsive.action.add")}
</Button>
</Group>
{form.values.layouts.map((layout, index) => (
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider
mt="xs"
min={1}
max={24}
step={1}
thumbLabel={t("layout.field.columnCount.label")}
{...form.getInputProps(`layouts.${index}.columnCount`)}
/>
</Input.Wrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={t("layout.field.breakpoint.label")}
description={t("layout.field.breakpoint.description")}
min={0}
max={32767}
allowDecimal={false}
allowNegative={false}
/>
</Grid.Col>
</Grid>
{form.values.layouts.length >= 2 && (
<Group justify="end">
<Button
variant="subtle"
onClick={() => {
form.setValues((previous) => {
const previousLayouts = previous.layouts ?? [];
return previousLayouts.length >= 2
? {
layouts: previousLayouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
}
: previous;
});
}}
>
{t("common.action.remove")}
</Button>
</Group>
)}
</Fieldset>
))}
</Stack>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -90,7 +90,10 @@ export default async function BoardSettingsPage(props: Props) {
<GeneralSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="layout" icon={IconLayout}>
<LayoutSettingsContent board={board} />
<LayoutSettingsContent
board={board}
enableAutomaticMobileLayout={boardSettings.enableAutomaticMobileLayout}
/>
</AccordionItemFor>
<AccordionItemFor value="background" icon={IconPhoto}>
<BackgroundSettingsContent board={board} />
+1
View File
@@ -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,
},
@@ -33,6 +33,13 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
{...form.getInputProps("mobileHomeBoardId")}
/>
<Text fw={500}>{tBoard("mobileLayout.title")}</Text>
<Switch
{...form.getInputProps("enableAutomaticMobileLayout", { type: "checkbox" })}
label={tBoard("mobileLayout.enable.label")}
description={tBoard("mobileLayout.enable.description")}
/>
<Text fw={500}>{tBoard("status.title")}</Text>
<Switch
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
@@ -1,6 +1,7 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
@@ -17,6 +18,7 @@ export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
defaultValues: ServerSettings[TKey];
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => 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 = <TKey extends keyof ServerSettings>({
showSuccessNotification({
message: tSettings("notification.success.message"),
});
router.refresh();
},
onError() {
showErrorNotification({
@@ -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,
@@ -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 &&
@@ -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,
@@ -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,
@@ -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 }),
]),
);
});
});
@@ -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[][]) => {
@@ -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);
@@ -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);
@@ -16,5 +16,4 @@
.item > :global(.grid-stack-item-content) {
width: 100%;
height: 100%;
overflow-y: auto;
}
@@ -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 },
]);
});
});
@@ -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,
@@ -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}
</Badge>
)}
{/* Use unique key by layout to reinitialize gridstack */}
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
<GridStack section={section} className="min-row" />
</Card>
<BoardDynamicSectionMenu section={section} />
</Box>
@@ -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 <GridStackForLayout key={currentLayoutId} section={section} {...props} />;
};
const GridStackForLayout = ({ section, ...props }: Props) => {
const { items, innerSections } = useSectionItems(section.id);
const itemIds = [...items, ...innerSections].map((item) => item.id);
@@ -103,6 +103,7 @@ export const useGridstack = (section: Omit<Section, "items">, 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<Section, "items">, 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<Section, "items">, 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<Section, "items">, 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<Section, "items">, 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<Section, "items">, 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
@@ -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,
@@ -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<boolean | undefined>(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;
};