feat: add automatic mobile board layout

This commit is contained in:
ajnart committed 2026-07-28 19:39:12 +02:00
1 parent 51a6cbe6d4
commit f133e09dc6
15 files changed
+441 -370

No files matched your search

@@ -4,30 +4,37 @@ import { Box, Stack } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { MobileBoard } from "~/components/board/mobile/mobile-board";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
import { BoardBackgroundVideo } from "~/components/layout/background";
export const ClientBoard = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const isMobile = useIsMobileBoard();
const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
return (
<Box h="100%" pos="relative">
<BoardBackgroundVideo />
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
{isMobile ? (
<MobileBoard />
) : (
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
)}
</Box>
);
};
@@ -38,6 +38,7 @@ import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
@@ -45,6 +46,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const isMobile = useIsMobileBoard();
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
@@ -52,9 +54,9 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return (
<>
{isEditMode && <AddMenu />}
{isEditMode && !isMobile && <AddMenu />}
<EditModeMenu demoReadOnly={demoReadOnly} />
<EditModeMenu demoReadOnly={demoReadOnly} hidden={isMobile} />
{!demoReadOnly && (
<OnboardingTour.Target id="board-settings">
@@ -157,7 +159,7 @@ const AddMenu = () => {
);
};
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard();
const utils = clientApi.useUtils();
@@ -186,16 +188,19 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
}, [utils, board.name, close]);
const toggle = useCallback(() => {
if (hidden) return;
if (isEditMode) {
if (demoReadOnly) return discardDemoChanges();
return saveBoard(board);
}
open();
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges, hidden]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode);
if (hidden) return null;
return (
<OnboardingTour.Target id="board-edit-mode">
<HeaderButton onClick={toggle} loading={isPending}>
@@ -1,12 +1,12 @@
"use client";
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
import { Button, Fieldset, Group, Input, Slider, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { getDesktopLayout } from "@homarr/boards/context";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
import { boardSaveLayoutSchema } from "@homarr/validation/board";
import type { Board } from "../../_types";
@@ -16,93 +16,43 @@ interface Props {
export const LayoutSettingsContent = ({ board }: Props) => {
const t = useI18n();
const utils = clientApi.useUtils();
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
const desktopLayout = getDesktopLayout(board);
const { mutate: saveLayout, isPending } = clientApi.board.saveLayout.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
const form = useZodForm(boardSaveLayoutSchema.omit({ id: true }).required(), {
initialValues: {
layouts: board.layouts,
columnCount: desktopLayout.columnCount,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
saveLayouts({
saveLayout({
id: board.id,
...values,
});
})}
>
<Stack>
<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: "",
columnCount: 10,
breakpoint: 0,
},
],
});
}}
>
{t("board.setting.section.layout.responsive.action.add")}
</Button>
</Group>
<Fieldset legend={t("board.setting.section.layout.desktop.title")} bg="transparent">
<Stack gap="sm">
<Text c="dimmed" size="sm">
{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")} />
</Input.Wrapper>
</Stack>
</Fieldset>
{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} {...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")}
/>
</Grid.Col>
</Grid>
{form.values.layouts.length >= 2 && (
<Group justify="end">
<Button
variant="subtle"
onClick={() => {
form.setValues((previous) =>
previous.layouts !== undefined && previous.layouts.length >= 2
? {
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
}
: previous,
);
}}
>
{t("common.action.remove")}
</Button>
</Group>
)}
</Fieldset>
))}
</Stack>
<Text c="dimmed" size="sm">
{t("board.setting.section.layout.mobile.description")}
</Text>
<Group justify="end">
<Button type="submit" loading={isPending}>
@@ -0,0 +1,20 @@
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-flow: row;
grid-auto-rows: min(calc((100vw - 42px) / 2), 12rem);
gap: 10px;
padding-bottom: 10px;
}
.item {
position: relative;
min-width: 0;
min-height: 0;
}
.item > :global(.grid-stack-item-content) {
width: 100%;
height: 100%;
overflow-y: auto;
}
@@ -0,0 +1,52 @@
"use client";
import type { RefObject } from "react";
import { useMemo, useRef } from "react";
import { Box } from "@mantine/core";
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
import { ReadOnlyEditModeProvider } from "@homarr/boards/edit-mode";
import type { GridItemHTMLElement, GridStack as GridStackInstance } from "@homarr/gridstack";
import { BoardItemContent } from "../items/item-content";
import { SectionProvider } from "../sections/section-context";
import classes from "./mobile-board.module.css";
import { createMobileBoardItems } from "./mobile-layout";
export const MobileBoard = () => {
const board = useRequiredBoard();
const desktopLayout = getDesktopLayout(board);
const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]);
const rootSection = board.sections.find((section) => section.kind !== "dynamic");
const wrapperRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
const gridstackRef = useRef<GridStackInstance | null>(null);
if (!rootSection) return null;
return (
<ReadOnlyEditModeProvider>
<SectionProvider
value={{
section: rootSection,
items,
innerSections: [],
refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef },
}}
>
<Box className={classes.grid} data-mobile-board>
{items.map((item) => (
<Box
key={item.id}
className={classes.item}
style={{ gridColumn: `span ${item.width}`, gridRow: `span ${item.height}` }}
data-mobile-board-item={item.id}
>
<BoardItemContent item={item} />
</Box>
))}
</Box>
</SectionProvider>
</ReadOnlyEditModeProvider>
);
};
@@ -0,0 +1,85 @@
import { describe, expect, test } from "vitest";
import { CategorySectionMockBuilder } from "../items/actions/test/mocks/category-section-mock";
import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock";
import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock";
import { EmptySectionMockBuilder } from "../items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock";
import { createMobileBoardItems } from "./mobile-layout";
describe("createMobileBoardItems", () => {
test("flattens categories and dynamic sections in visual order", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
const rootSection = new EmptySectionMockBuilder({ id: "root", yOffset: 0 }).build();
const category = new CategorySectionMockBuilder({ id: "category", yOffset: 1, collapsed: true }).build();
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
.addLayout({ layoutId: desktopLayoutId, parentSectionId: rootSection.id, xOffset: 0, yOffset: 1 })
.build();
const nestedDynamicSection = new DynamicSectionMockBuilder({ id: "nested-dynamic" })
.addLayout({ layoutId: desktopLayoutId, parentSectionId: dynamicSection.id, xOffset: 0, yOffset: 1 })
.build();
board.sections.push(rootSection, category, dynamicSection, nestedDynamicSection);
board.items.push(
new ItemMockBuilder({ id: "second" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, xOffset: 1, yOffset: 0 })
.build(),
new ItemMockBuilder({ id: "first" })
.addLayout({
layoutId: desktopLayoutId,
sectionId: rootSection.id,
xOffset: 0,
yOffset: 0,
width: 8,
height: 6,
})
.build(),
new ItemMockBuilder({ id: "inside-dynamic" })
.addLayout({ layoutId: desktopLayoutId, sectionId: dynamicSection.id })
.build(),
new ItemMockBuilder({ id: "inside-nested-dynamic" })
.addLayout({ layoutId: desktopLayoutId, sectionId: nestedDynamicSection.id })
.build(),
new ItemMockBuilder({ id: "inside-collapsed-category" })
.addLayout({ layoutId: desktopLayoutId, sectionId: category.id })
.build(),
);
const result = createMobileBoardItems(board, desktopLayoutId);
expect(result.map((item) => item.id)).toEqual([
"first",
"second",
"inside-dynamic",
"inside-nested-dynamic",
"inside-collapsed-category",
]);
expect(result[0]).toMatchObject({ width: 2, height: 3, xOffset: 0, yOffset: 0 });
});
test("uses the desktop layout and keeps orphaned items", () => {
const board = new BoardMockBuilder().build();
const legacyLayoutId = board.layouts.at(0)?.id;
if (!legacyLayoutId) throw new Error("Expected a legacy layout");
const desktopLayoutId = "desktop";
board.layouts.push({ id: desktopLayoutId, name: "Large", columnCount: 16, breakpoint: 1200 });
board.sections.push(new EmptySectionMockBuilder({ id: "root" }).build());
board.items.push(
new ItemMockBuilder({ id: "first" })
.addLayout({ layoutId: legacyLayoutId, sectionId: "root", yOffset: 2 })
.addLayout({ layoutId: desktopLayoutId, sectionId: "missing-section", yOffset: 0, width: 0, height: 0 })
.build(),
new ItemMockBuilder({ id: "second" })
.addLayout({ layoutId: legacyLayoutId, sectionId: "root", yOffset: 0 })
.addLayout({ layoutId: desktopLayoutId, sectionId: "root", yOffset: 1 })
.build(),
);
const result = createMobileBoardItems(board, desktopLayoutId);
expect(result.map((item) => item.id)).toEqual(["second", "first"]);
expect(result[1]).toMatchObject({ width: 1, height: 1 });
});
});
@@ -0,0 +1,106 @@
import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types";
const mobileColumnCount = 2;
const mobileMaxHeight = 3;
type PositionedElement = DynamicSectionItem | SectionItem;
type PositionedLayout = {
layoutId: string;
xOffset: number;
yOffset: number;
width: number;
height: number;
};
const comparePosition = (elementA: PositionedElement, elementB: PositionedElement) =>
elementA.yOffset - elementB.yOffset || elementA.xOffset - elementB.xOffset;
const getLayout = <TLayout extends PositionedLayout>(
layouts: TLayout[],
desktopLayoutId: string,
layoutPriority: Map<string, number>,
) =>
layouts.find((layout) => layout.layoutId === desktopLayoutId) ??
layouts.toSorted(
(layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1),
)[0];
export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => {
const rootSections = board.sections
.filter((section) => section.kind !== "dynamic")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset || sectionA.xOffset - sectionB.xOffset);
const defaultSectionId = rootSections[0]?.id ?? "mobile";
const layoutPriority = new Map(
board.layouts
.toSorted(
(layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint || layoutA.columnCount - layoutB.columnCount,
)
.map((layout, index) => [layout.id, index]),
);
const items = board.items.map(({ layouts, ...item }, index): SectionItem => {
const layout =
getLayout(layouts, desktopLayoutId, layoutPriority) ??
({
layoutId: desktopLayoutId,
xOffset: 0,
yOffset: index,
width: 1,
height: 1,
sectionId: defaultSectionId,
} satisfies ItemLayout);
return { ...item, ...layout, type: "item" };
});
const dynamicSections = board.sections
.filter((section) => section.kind === "dynamic")
.map(({ layouts, ...section }): DynamicSectionItem | null => {
const layout = getLayout(layouts, desktopLayoutId, layoutPriority);
return layout ? { ...section, ...layout, type: "section" } : null;
})
.filter((section) => section !== null);
const elementsBySection = new Map<string, PositionedElement[]>();
for (const element of [...items, ...dynamicSections]) {
const parentSectionId = element.type === "item" ? element.sectionId : element.parentSectionId;
const elements = elementsBySection.get(parentSectionId) ?? [];
elements.push(element);
elementsBySection.set(parentSectionId, elements);
}
const result: SectionItem[] = [];
const visitedItems = new Set<string>();
const visitedSections = new Set<string>();
const appendSectionItems = (sectionId: string) => {
if (visitedSections.has(sectionId)) return;
visitedSections.add(sectionId);
for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) {
if (element.type === "section") {
appendSectionItems(element.id);
continue;
}
if (!visitedItems.has(element.id)) {
visitedItems.add(element.id);
result.push(element);
}
}
};
rootSections.forEach((section) => appendSectionItems(section.id));
dynamicSections.toSorted(comparePosition).forEach((section) => appendSectionItems(section.id));
items.toSorted(comparePosition).forEach((item) => {
if (!visitedItems.has(item.id)) result.push(item);
});
return result.map((item, index) => ({
...item,
xOffset: 0,
yOffset: index,
width: Math.max(1, Math.min(item.width, mobileColumnCount)),
height: Math.max(1, Math.min(item.height, mobileMaxHeight)),
}));
};
@@ -0,0 +1,7 @@
"use client";
import { useMediaQuery } from "@mantine/hooks";
export const mobileBoardMediaQuery = "(max-width: 48em)";
export const useIsMobileBoard = () => useMediaQuery(mobileBoardMediaQuery);