refactor: simplify mobile board layouts

This commit is contained in:
ajnart committed 2026-08-08 13:54:42 +02:00
1 parent 7e7b338d18
commit fa9444323b
51 files changed
+383 -2472

No files matched your search

@@ -4,17 +4,13 @@ import { Box, Stack } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { MobileBoard } from "~/components/board/mobile/mobile-board";
import { MobileBoardPreview } from "~/components/board/mobile/mobile-preview";
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")
@@ -23,20 +19,15 @@ export const ClientBoard = () => {
return (
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
{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>
)}
<MobileBoardPreview />
<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>
);
};
@@ -19,7 +19,6 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
import { HeaderButton } from "~/components/layout/header/button";
import { TourTarget } from "~/components/layout/header/tour-target";
@@ -37,7 +36,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const isMobile = useIsMobileBoard();
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
@@ -45,9 +43,9 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return (
<>
{isEditMode && !isMobile && <BoardAddMenu />}
{isEditMode && <BoardAddMenu />}
<EditModeMenu demoReadOnly={demoReadOnly} hidden={isMobile} />
<EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && (
<TourTarget id="board-settings">
@@ -62,7 +60,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
);
};
const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden: boolean }) => {
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard();
const utils = clientApi.useUtils();
@@ -91,25 +89,21 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
}, [utils, board.name, close]);
const toggle = useCallback(() => {
if (hidden && !isEditMode) return;
if (isEditMode) {
if (demoReadOnly) return discardDemoChanges();
return saveBoard(board);
}
open();
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges, hidden]);
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode);
if (hidden && !isEditMode) return null;
return (
<TourTarget id="board-edit-mode">
<HeaderButton
onClick={toggle}
loading={isPending}
aria-label={t("label")}
onFocus={preloadBoardAddMenu}
onPointerEnter={preloadBoardAddMenu}
>
@@ -3,116 +3,30 @@
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, boardSaveLayoutsSchema } from "@homarr/validation/board";
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
import type { Board } from "../../_types";
interface Props {
board: Board;
enableAutomaticMobileLayout: boolean;
}
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">) => {
export const LayoutSettingsContent = ({ board }: Props) => {
const t = useI18n();
const utils = clientApi.useUtils();
const desktopLayout = getDesktopLayout(board);
const { mutate: saveLayout, isPending } = clientApi.board.saveLayout.useMutation({
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const form = useZodForm(boardSaveLayoutSchema.omit({ id: true }).required(), {
initialValues: {
columnCount: desktopLayout.columnCount,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
saveLayout({
id: board.id,
...values,
});
})}
>
<Stack>
<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}
thumbLabel={t("layout.field.columnCount.label")}
{...form.getInputProps("columnCount")}
/>
</Input.Wrapper>
</Stack>
</Fieldset>
<Text c="dimmed" size="sm">
{t("board.setting.section.layout.mobile.automaticDescription")}
</Text>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</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
@@ -124,9 +38,6 @@ const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
})}
>
<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>
@@ -138,9 +49,9 @@ const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
...form.values.layouts,
{
id: createId(),
name: t("board.setting.section.layout.responsive.defaultName"),
name: "",
columnCount: 10,
breakpoint: getNextAvailableBreakpoint(form.values.layouts),
breakpoint: 0,
},
],
});
@@ -159,14 +70,7 @@ const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
<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`)}
/>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
</Input.Wrapper>
</Grid.Col>
@@ -175,10 +79,6 @@ const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
{...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>
@@ -187,14 +87,13 @@ const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
<Button
variant="subtle"
onClick={() => {
form.setValues((previous) => {
const previousLayouts = previous.layouts ?? [];
return previousLayouts.length >= 2
form.setValues((previous) =>
previous.layouts !== undefined && previous.layouts.length >= 2
? {
layouts: previousLayouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
}
: previous;
});
: previous,
);
}}
>
{t("common.action.remove")}
@@ -90,10 +90,7 @@ export default async function BoardSettingsPage(props: Props) {
<GeneralSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="layout" icon={IconLayout}>
<LayoutSettingsContent
board={board}
enableAutomaticMobileLayout={boardSettings.enableAutomaticMobileLayout}
/>
<LayoutSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="background" icon={IconPhoto}>
<BackgroundSettingsContent board={board} />
@@ -1,5 +1,4 @@
import type { JSX, PropsWithChildren } from "react";
import { headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
@@ -8,11 +7,9 @@ import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import { userAgent } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { MainHeader } from "~/components/layout/header";
import { MobileBoardViewportProvider } from "~/components/board/use-mobile-board";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
@@ -50,7 +47,6 @@ export const createBoardLayout = <TParams extends Params>({
(error: unknown) => ({ status: "rejected", error }) as const,
);
const colorSchemePromise = getCurrentColorSchemeAsync();
const requestHeadersPromise = headers();
const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
if (!withTour || !session || env.DEMO_MODE) return false;
@@ -62,11 +58,10 @@ export const createBoardLayout = <TParams extends Params>({
return false;
}
});
const [session, initialBoardResult, colorScheme, requestHeaders, shouldRunBoardTour] = await Promise.all([
const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
sessionPromise,
initialBoardPromise,
colorSchemePromise,
requestHeadersPromise,
shouldRunBoardTourPromise,
]);
if (initialBoardResult.status === "rejected") {
@@ -88,31 +83,27 @@ export const createBoardLayout = <TParams extends Params>({
throw error;
}
const initialBoard = initialBoardResult.board;
const deviceType = userAgent(new Headers(requestHeaders)).device.type;
const initialIsMobile = deviceType === "mobile" || deviceType === "tablet";
return (
<MobileBoardViewportProvider initialIsMobile={initialIsMobile}>
<BoardProvider initialBoard={initialBoard}>
<BoardReadyProvider>
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardTourGate>
</BoardMantineProvider>
</EditModeProvider>
</BoardReadyProvider>
</BoardProvider>
</MobileBoardViewportProvider>
<BoardProvider initialBoard={initialBoard}>
<BoardReadyProvider>
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardTourGate>
</BoardMantineProvider>
</EditModeProvider>
</BoardReadyProvider>
</BoardProvider>
);
};
-1
View File
@@ -121,7 +121,6 @@ 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,13 +33,6 @@ 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,7 +1,6 @@
"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";
@@ -18,7 +17,6 @@ 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({
@@ -26,7 +24,6 @@ export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
showSuccessNotification({
message: tSettings("notification.success.message"),
});
router.refresh();
},
onError() {
showErrorNotification({
@@ -60,15 +60,9 @@ 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, layoutItemSize)
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
@@ -76,8 +70,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
}
return {
width: layoutItemSize.width,
height: layoutItemSize.height,
width: itemSize.width,
height: itemSize.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 + 1 - size.width; xOffset++) {
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
const isOccupied = elements.some(
(element) =>
element.yOffset < yOffset + size.height &&
@@ -1,8 +1,9 @@
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;
@@ -10,8 +11,10 @@ export interface MoveAndResizeItemInput {
}
export const moveAndResizeItemCallback =
({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) =>
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
(previous: Board): Board => {
const currentLayout = getCurrentLayout(previous);
return {
...previous,
items: previous.items.map((item) =>
@@ -20,7 +23,7 @@ export const moveAndResizeItemCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
layout.layoutId !== layoutId
layout.layoutId !== currentLayout
? layout
: {
...layout,
@@ -1,8 +1,9 @@
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;
@@ -11,8 +12,10 @@ export interface MoveItemToSectionInput {
}
export const moveItemToSectionCallback =
({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) =>
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
(board: Board): Board => {
const currentLayout = getCurrentLayout(board);
return {
...board,
items: board.items.map((item) =>
@@ -21,7 +24,7 @@ export const moveItemToSectionCallback =
: {
...item,
layouts: item.layouts.map((layout) =>
layout.layoutId !== layoutId
layout.layoutId !== currentLayout
? layout
: {
...layout,
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
@@ -10,10 +10,6 @@ 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";
@@ -112,24 +108,4 @@ 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,10 +106,6 @@ 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,4 +1,6 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { moveAndResizeItemCallback } from "../move-and-resize-item";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -9,6 +11,8 @@ 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,
@@ -21,7 +25,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, layoutId, ...newPosition })(board);
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -39,6 +43,8 @@ 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,
@@ -50,7 +56,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
@@ -1,4 +1,6 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { moveItemToSectionCallback } from "../move-item-to-section";
import { BoardMockBuilder } from "./mocks/board-mock";
@@ -9,6 +11,8 @@ 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,
@@ -22,7 +26,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, layoutId, ...newPosition })(board);
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(3);
@@ -40,6 +44,8 @@ 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,
@@ -52,7 +58,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
// Act
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
// Assert
expect(updatedBoard.items).toHaveLength(2);
@@ -1,23 +0,0 @@
.container {
container-type: inline-size;
}
.grid {
display: grid;
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
grid-auto-flow: row;
grid-auto-rows: min(calc((100cqw - 42px) / var(--mobile-column-count)), 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%;
}
@@ -1,56 +0,0 @@
"use client";
import type { CSSProperties, 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, getMobileRootSection, mobileColumnCount } 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 = getMobileRootSection(board);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
const gridstackRef = useRef<GridStackInstance | null>(null);
return (
<ReadOnlyEditModeProvider>
<SectionProvider
value={{
section: rootSection,
items,
innerSections: [],
refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef },
}}
>
<Box ref={wrapperRef} className={classes.container}>
<Box
className={classes.grid}
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
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>
</Box>
</SectionProvider>
</ReadOnlyEditModeProvider>
);
};
@@ -1,154 +0,0 @@
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, createMobileBoardPreviewItems, getMobileRootSection } from "./mobile-layout";
describe("createMobileBoardItems", () => {
test("selects the first root section in visual order", () => {
const board = new BoardMockBuilder().build();
board.sections.push(
new EmptySectionMockBuilder({ id: "stored-first", xOffset: 0, yOffset: 1 }).build(),
new EmptySectionMockBuilder({ id: "visually-first", xOffset: 1, yOffset: 0 }).build(),
new EmptySectionMockBuilder({ id: "visually-second", xOffset: 2, yOffset: 0 }).build(),
);
expect(getMobileRootSection(board).id).toBe("visually-first");
});
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 });
});
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 },
]);
});
});
describe("createMobileBoardPreviewItems", () => {
test("returns no tiles for an empty board", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
expect(createMobileBoardPreviewItems(board, desktopLayoutId)).toEqual([]);
});
test("uses the production mobile order, footprint, kind, app ID, and custom title", () => {
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: "wide", kind: "notebook" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 8, height: 6, yOffset: 1 })
.build(),
new ItemMockBuilder({ id: "first", kind: "app" })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 1, height: 1, yOffset: 0 })
.build(),
);
const appItem = board.items.find((item) => item.id === "first");
if (!appItem || appItem.kind !== "app") throw new Error("Expected app board item");
appItem.options.appId = "actual-app";
const wideItem = board.items.find((item) => item.id === "wide");
if (!wideItem) throw new Error("Expected wide board item");
wideItem.advancedOptions.title = "Notes";
expect(createMobileBoardPreviewItems(board, desktopLayoutId)).toEqual([
{ id: "first", kind: "app", appId: "actual-app", width: 1, height: 1, title: null },
{ id: "wide", kind: "notebook", appId: null, width: 2, height: 3, title: "Notes" },
]);
});
});
@@ -1,127 +0,0 @@
import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types";
export 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 getMobileRootSections = (board: Board) =>
board.sections
.filter((section) => section.kind !== "dynamic")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset || sectionA.xOffset - sectionB.xOffset);
export const getMobileRootSection = (board: Board) =>
getMobileRootSections(board)[0] ?? {
id: "mobile",
kind: "empty" as const,
xOffset: 0,
yOffset: 0,
};
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 = getMobileRootSections(board);
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)),
}));
};
export const createMobileBoardPreviewItems = (board: Board, desktopLayoutId: string) =>
createMobileBoardItems(board, desktopLayoutId).map((item) => ({
id: item.id,
kind: item.kind,
appId: item.kind === "app" && typeof item.options.appId === "string" ? item.options.appId : null,
width: item.width,
height: item.height,
title: item.advancedOptions.title,
}));
@@ -1,44 +0,0 @@
.panel {
width: min(390px, calc(100vw - var(--mantine-spacing-xl)));
max-height: min(70vh, 700px);
overflow: hidden;
}
.viewport {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
}
.grid {
display: grid;
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
grid-auto-flow: row;
grid-auto-rows: 42px;
gap: var(--mantine-spacing-xs);
}
.item {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
}
.itemContent {
min-width: 0;
}
.appIcon {
width: 14px;
height: 14px;
}
@media (prefers-reduced-motion: reduce) {
.panel,
.item {
scroll-behavior: auto;
transition: none;
}
}
@@ -1,128 +0,0 @@
"use client";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { ActionIcon, Affix, Button, Center, Group, Paper, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconDeviceMobile, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { useIsMobileBoard } from "../use-mobile-board";
import classes from "./mobile-preview.module.css";
import { createMobileBoardPreviewItems, mobileColumnCount } from "./mobile-layout";
export const MobileBoardPreview = () => {
const [isEditMode] = useEditMode();
const [isExpanded, setIsExpanded] = useState(false);
const isMobileBoard = useIsMobileBoard();
const { enableAutomaticMobileLayout } = useSettings();
const t = useI18n();
useEffect(() => {
if (!isEditMode || isMobileBoard || !enableAutomaticMobileLayout) {
setIsExpanded(false);
}
}, [enableAutomaticMobileLayout, isEditMode, isMobileBoard]);
if (!isEditMode || isMobileBoard || !enableAutomaticMobileLayout) return null;
return (
<Affix position={{ bottom: "var(--mantine-spacing-md)", left: "var(--mantine-spacing-md)" }} zIndex={200}>
{isExpanded ? (
<Paper className={classes.panel} radius="md" shadow="md" data-mobile-board-preview>
<Group justify="space-between" wrap="nowrap" p="sm">
<Group gap="xs" wrap="nowrap">
<IconDeviceMobile size={20} aria-hidden />
<Text fw={600}>{t("board.mobilePreview.title")}</Text>
</Group>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setIsExpanded(false)}
aria-label={t("board.mobilePreview.collapse")}
>
<IconX size={18} />
</ActionIcon>
</Group>
<MobileBoardPreviewMap />
</Paper>
) : (
<Button
leftSection={<IconDeviceMobile size={20} />}
onClick={() => setIsExpanded(true)}
data-mobile-board-preview-toggle
>
{t("board.mobilePreview.action")}
</Button>
)}
</Affix>
);
};
const MobileBoardPreviewMap = () => {
const board = useRequiredBoard();
const desktopLayout = getDesktopLayout(board);
const items = useMemo(() => createMobileBoardPreviewItems(board, desktopLayout.id), [board, desktopLayout.id]);
const appIds = useMemo(
() => [...new Set(items.flatMap((item) => (item.appId === null ? [] : [item.appId])))],
[items],
);
const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 });
const appsById = useMemo(() => new Map(apps.map((app) => [app.id, app])), [apps]);
const t = useI18n();
return (
<ScrollArea.Autosize className={classes.viewport} mah="calc(min(70vh, 700px) - 52px)" p="sm">
{items.length === 0 ? (
<Center mih={160}>
<Stack gap={4} align="center">
<IconDeviceMobile size={28} color="var(--mantine-color-dimmed)" aria-hidden />
<Text size="sm" c="dimmed" ta="center">
{t("board.mobilePreview.empty")}
</Text>
</Stack>
</Center>
) : (
<div
className={classes.grid}
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
data-mobile-board-preview-grid
>
{items.map((item) => {
const WidgetIcon = widgetCatalogIcons[item.kind];
const app = item.appId === null ? undefined : appsById.get(item.appId);
const label = app?.name ?? (item.title?.trim() || t(`widget.${item.kind}.name`));
return (
<div
key={item.id}
className={classes.item}
style={{ gridColumn: `span ${item.width}`, gridRow: `span ${item.height}` }}
data-mobile-board-preview-item={item.id}
>
<Group className={classes.itemContent} gap={6} wrap="nowrap" px="xs">
<ThemeIcon variant="light" size="sm" data-mobile-board-preview-app-icon={app?.id}>
{app ? (
<MaskedOrNormalImage imageUrl={app.iconUrl} hasColor={false} alt="" className={classes.appIcon} />
) : (
<WidgetIcon size={14} aria-hidden />
)}
</ThemeIcon>
<Text size="xs" fw={500} truncate title={label}>
{label}
</Text>
</Group>
</div>
);
})}
</div>
)}
</ScrollArea.Autosize>
);
};
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
@@ -18,7 +18,6 @@ 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) => {
@@ -105,6 +104,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => {
const currentLayoutId = getCurrentLayout(board);
const appIds = (
await filterByItemKind(
board.items.filter(
@@ -132,7 +132,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
});
break;
}
}, [category, board, currentLayoutId, t, openConfirmModal, settings]);
}, [category, board, t, openConfirmModal, settings]);
return {
addCategoryAbove,
@@ -1,7 +1,7 @@
import { Badge, Box, Card } from "@mantine/core";
import combineClasses from "clsx";
import { useRequiredBoard } from "@homarr/boards/context";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
@@ -14,6 +14,7 @@ interface Props {
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const options = section.options;
return (
@@ -51,7 +52,8 @@ export const BoardDynamicSection = ({ section }: Props) => {
{options.title}
</Badge>
)}
<GridStack section={section} className="min-row" />
{/* Use unique key by layout to reinitialize gridstack */}
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
</Card>
<BoardDynamicSectionMenu section={section} />
</Box>
@@ -4,8 +4,6 @@ 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";
@@ -17,12 +15,6 @@ 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,7 +103,6 @@ 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
@@ -121,7 +120,6 @@ 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
@@ -138,7 +136,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`);
},
[currentLayoutId, moveAndResizeItem, moveAndResizeInnerSection],
[moveAndResizeItem, moveAndResizeInnerSection],
);
const onAdd = useCallback(
(addedNode: GridStackNode) => {
@@ -151,7 +149,6 @@ 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
@@ -170,7 +167,6 @@ 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
@@ -188,7 +184,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`);
},
[currentLayoutId, moveItemToSection, moveInnerSectionToSection, section.id],
[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,7 +12,6 @@ interface MoveAndResizeInnerSection {
}
interface MoveInnerSectionToSection {
innerSectionId: string;
layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -24,7 +23,7 @@ export const useSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const moveAndResizeInnerSection = useCallback(
({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => {
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((section) => {
@@ -32,10 +31,12 @@ 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 !== layoutId) return layout;
if (layout.layoutId !== currentLayout) return layout;
return {
...layout,
...positionProps,
@@ -49,7 +50,7 @@ export const useSectionActions = () => {
);
const moveInnerSectionToSection = useCallback(
({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
updateBoard((previous) => {
return {
...previous,
@@ -58,10 +59,12 @@ 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 !== layoutId) return layout;
if (layout.layoutId !== currentLayout) return layout;
return {
...layout,
...positionProps,
@@ -1,26 +0,0 @@
"use client";
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);
export const MobileBoardViewportProvider = ({
initialIsMobile,
children,
}: PropsWithChildren<{ initialIsMobile: boolean }>) => (
<InitialMobileBoardContext.Provider value={initialIsMobile}>{children}</InitialMobileBoardContext.Provider>
);
export const useIsMobileBoard = () => {
const initialIsMobile = useContext(InitialMobileBoardContext);
const { enableAutomaticMobileLayout } = useSettings();
const isMobileViewport = useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
return enableAutomaticMobileLayout && isMobileViewport;
};